普通视图

发现新文章,点击刷新页面。
昨天 — 2025年10月23日首页
昨天以前首页

VitePress 文档站点:打造专业级组件文档(含交互式示例)

2025年10月20日 16:54

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第六篇。这篇文章将教你如何搭建一个媲美官方文档的专业文档站点。

🎯 本文目标

  • VitePress 在 Monorepo 中的集成
  • 组件示例自动导入和渲染
  • 自定义主题和样式
  • 文档站点性能优化
  • 部署配置

📖 为什么选择 VitePress?

文档工具对比

工具 性能 Vue 支持 Markdown 扩展 学习成本 推荐指数
VuePress ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Docusaurus ⭐⭐⭐ ❌ React ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Docsify ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐
VitePress ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

VitePress 优势:

  • ⚡ 基于 Vite,开发和构建速度极快
  • 🎨 Vue 组件可以直接在 Markdown 中使用
  • 📝 Markdown 扩展能力强大
  • 🎯 专为 Vue 生态设计

🏗️ 项目结构设计

docs/
├── .vitepress/
│   ├── config.ts              # 配置文件
│   ├── theme/
│   │   ├── index.ts           # 主题入口
│   │   ├── components/
│   │   │   └── DemoBlock.vue  # 示例容器组件
│   │   └── custom.css         # 自定义样式
│   └── plugins/
│       └── demo-container.ts  # 自定义插件
├── components/
│   ├── button/
│   │   ├── index.md           # Button 文档
│   │   └── basic.vue          # 基础示例
│   └── index.md
├── guide/
│   ├── index.md
│   └── getting-started.md
├── index.md                   # 首页
└── package.json

🚀 第一步:安装和配置

1.1 安装 VitePress

cd docs
pnpm init
pnpm add -D vitepress vue

1.2 基础配置

// docs/.vitepress/config.ts
import { defineConfig } from 'vitepress'
import { resolve } from 'path'

export default defineConfig({
  title: 'GDU Common',
  description: 'GDU 前端通用组件库和工具集',
  lang: 'zh-CN',

  // 主题配置
  themeConfig: {
    logo: '/logo.svg',

    nav: [
      { text: '指南', link: '/guide/' },
      { text: '组件', link: '/components/' },
      { text: '工具', link: '/utils/' },
    ],

    sidebar: {
      '/guide/': [
        {
          text: '开始',
          items: [
            { text: '介绍', link: '/guide/' },
            { text: '快速开始', link: '/guide/getting-started' },
          ],
        },
      ],
      '/components/': [
        {
          text: '组件',
          items: [{ text: 'Button 按钮', link: '/components/button' }],
        },
      ],
    },

    socialLinks: [{ icon: 'github', link: 'https://github.com/your-org/gdu-common' }],

    search: {
      provider: 'local', // 本地搜索
    },
  },

  // Vite 配置
  vite: {
    resolve: {
      alias: {
        '@gdu-common/ui': resolve(__dirname, '../../packages/ui/src'),
        '@gdu-common/utils': resolve(__dirname, '../../packages/utils/src'),
        '@gdu-common/shared': resolve(__dirname, '../../packages/shared/src'),
      },
    },
  },
})

1.3 添加脚本

// docs/package.json
{
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview"
  }
}

🎨 第二步:自定义主题

2.1 创建自定义样式

/* docs/.vitepress/theme/custom.css */

/* 首页渐变背景 */
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: linear-gradient(120deg, #bd34fe 30%, #41d1ff);
}

/* 自定义品牌色 */
:root {
  --vp-c-brand-1: #42b983;
  --vp-c-brand-2: #35a069;
  --vp-c-brand-3: #299e5c;
}

/* 代码块样式优化 */
.vp-code-group {
  margin: 16px 0;
}

/* 表格样式 */
.vp-doc table {
  display: block;
  overflow-x: auto;
}

2.2 注册全局组件

// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import './custom.css'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    // 自动注册所有示例组件
    const examples = import.meta.glob('../../components/**/*.vue', {
      eager: true,
    })

    for (const path in examples) {
      const component = examples[path].default
      const name = path.match(/\/([^/]+)\.vue$/)?.[1]
      if (name) {
        app.component(name, component)
      }
    }
  },
}

🎭 第三步:交互式组件示例

3.1 创建 Demo 容器组件

<!-- docs/.vitepress/theme/components/DemoBlock.vue -->
<template>
  <div class="demo-block">
    <div class="demo-preview">
      <slot name="demo" />
    </div>

    <div class="demo-actions">
      <button @click="toggleCode" class="toggle-code-btn">
        {{ showCode ? '隐藏代码' : '查看代码' }}
      </button>
    </div>

    <div v-show="showCode" class="demo-code">
      <slot name="code" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const showCode = ref(false)
const toggleCode = () => {
  showCode.value = !showCode.value
}
</script>

<style scoped>
.demo-block {
  border: 1px solid var(--vp-c-divider);
  border-radius: 8px;
  margin: 16px 0;
}

.demo-preview {
  padding: 24px;
  background: var(--vp-c-bg-soft);
}

.demo-actions {
  padding: 12px 16px;
  border-top: 1px solid var(--vp-c-divider);
  display: flex;
  justify-content: flex-end;
}

.toggle-code-btn {
  padding: 4px 12px;
  font-size: 14px;
  border: 1px solid var(--vp-c-brand-1);
  color: var(--vp-c-brand-1);
  background: transparent;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.toggle-code-btn:hover {
  background: var(--vp-c-brand-1);
  color: white;
}

.demo-code {
  border-top: 1px solid var(--vp-c-divider);
}
</style>

3.2 Markdown 自定义容器插件

// docs/.vitepress/plugins/demo-container.ts
import MarkdownIt from 'markdown-it'
import container from 'markdown-it-container'
import { readFileSync } from 'fs'
import { resolve } from 'path'

export default (md: MarkdownIt) => {
  md.use(container, 'demo', {
    validate(params: string) {
      return params.trim().match(/^demo\s+(.*)$/)
    },

    render(tokens: any[], idx: number) {
      const token = tokens[idx]
      const info = token.info.trim().match(/^demo\s+(.*)$/)

      if (tokens[idx].nesting === 1) {
        // 开始标签
        const demoPath = info?.[1] || ''
        const filePath = resolve(__dirname, '../../components', `${demoPath}.vue`)
        const source = readFileSync(filePath, 'utf-8')

        return `
          <DemoBlock>
            <template #demo>
              <${demoPath.split('/').pop()} />
            </template>
            <template #code>
              
\`\`\`vue
${source}
\`\`\`

            </template>
          </DemoBlock>
          <div>
        `
      } else {
        // 结束标签
        return '</div>\n'
      }
    },
  })
}

3.3 在 config.ts 中注册插件

import demoContainer from './plugins/demo-container'

export default defineConfig({
  markdown: {
    config: md => {
      md.use(demoContainer)
    },
  },
})

📝 第四步:编写组件文档

4.1 创建示例组件

<!-- docs/components/button/basic.vue -->
<template>
  <div class="demo">
    <Button>默认按钮</Button>
    <Button type="primary">主要按钮</Button>
    <Button type="success">成功按钮</Button>
  </div>
</template>

<script setup lang="ts">
import { Button } from '@gdu-common/ui'
</script>

<style scoped>
.demo {
  display: flex;
  gap: 12px;
}
</style>

4.2 编写文档

<!-- docs/components/button.md -->

# Button 按钮

常用的操作按钮。

## 基础用法

最简单的按钮用法。

:::demo button/basic
:::

## API

### Props

| 属性     | 说明     | 类型                                  | 默认值      |
| -------- | -------- | ------------------------------------- | ----------- |
| type     | 按钮类型 | `'default' \| 'primary' \| 'success'` | `'default'` |
| size     | 按钮尺寸 | `'small' \| 'medium' \| 'large'`      | `'medium'`  |
| loading  | 加载状态 | `boolean`                             | `false`     |
| disabled | 禁用状态 | `boolean`                             | `false`     |

### Events

| 事件名 | 说明           | 回调参数                      |
| ------ | -------------- | ----------------------------- |
| click  | 点击按钮时触发 | `(event: MouseEvent) => void` |

### Slots

| 插槽名  | 说明       |
| ------- | ---------- |
| default | 按钮内容   |
| icon    | 自定义图标 |

效果:

  • 📖 文档和代码在一起,易于维护
  • 🎨 实时渲染的组件示例
  • 💻 可以查看源代码
  • 📱 响应式设计

🎨 第五步:首页定制

5.1 英雄区域

## <!-- docs/index.md -->

layout: home

hero:
name: GDU Common
text: 企业级前端通用组件库
tagline: 基于 Vue 3 + TypeScript + Vite
image:
src: /logo.svg
alt: GDU Common
actions: - theme: brand
text: 快速开始
link: /guide/getting-started - theme: alt
text: 查看组件
link: /components/ - theme: alt  
 text: GitHub
link: https://github.com/your-org/gdu-common

---

5.2 特性展示

features:

- icon: 🎨
  title: Vue 3 组件库
  details: 基于 Vue 3 Composition API 开发,提供丰富且高质量的 UI 组件
  link: /components/
  linkText: 查看组件
- icon: ⚡
  title: Vite & Turborepo
  details: 使用 Vite 极速构建,Turborepo 智能缓存,构建速度提升 19 倍
- icon: 🔧
  title: TypeScript 优先
  details: 完整的类型定义和智能提示,提供一流的开发体验
- icon: 📦
  title: Monorepo 架构
  details: 使用 pnpm workspace + Turborepo 管理,支持多包开发和发布

5.3 自定义样式

<!-- docs/index.md -->
<style>
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: linear-gradient(-45deg, #bd34fe 30%, #41d1ff);

  --vp-home-hero-image-background-image: linear-gradient(-45deg, #bd34fe50 50%, #47caff50);
  --vp-home-hero-image-filter: blur(44px);
}

.VPFeature {
  transition: all 0.3s;
}

.VPFeature:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
</style>

📱 第六步:高级功能

6.1 组件 Playground

<!-- docs/.vitepress/theme/components/Playground.vue -->
<template>
  <div class="playground">
    <div class="playground-preview">
      <component :is="currentComponent" v-bind="props" />
    </div>

    <div class="playground-controls">
      <h4>属性配置</h4>
      <div v-for="(value, key) in props" :key="key" class="control-item">
        <label>{{ key }}</label>
        <input v-model="props[key]" />
      </div>
    </div>

    <div class="playground-code">
      <pre><code>{{ generatedCode }}</code></pre>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive } from 'vue'
import { Button } from '@gdu-common/ui'

const props = reactive({
  type: 'primary',
  size: 'medium',
  loading: false,
})

const currentComponent = Button

const generatedCode = computed(() => {
  const propsStr = Object.entries(props)
    .map(([key, val]) => `${key}="${val}"`)
    .join(' ')
  return `<Button ${propsStr}>点击我</Button>`
})
</script>

6.2 API 表格自动生成

// docs/.vitepress/plugins/api-table.ts
import { readFileSync } from 'fs'
import { parse } from 'vue/compiler-sfc'

export function extractProps(filePath: string) {
  const source = readFileSync(filePath, 'utf-8')
  const { descriptor } = parse(source)

  // 解析 <script setup> 中的 defineProps
  const scriptContent = descriptor.script?.content || ''
  const propsMatch = scriptContent.match(/defineProps<(.+)>/)

  if (propsMatch) {
    // 提取 Props 类型定义
    return parsePropsType(propsMatch[1])
  }

  return []
}

6.3 暗黑模式支持

// config.ts
export default defineConfig({
  appearance: true, // 启用暗黑模式切换

  themeConfig: {
    // 暗黑模式下的logo
    logo: {
      light: '/logo-light.svg',
      dark: '/logo-dark.svg',
    },
  },
})
/* 暗黑模式样式 */
.dark .demo-block {
  border-color: var(--vp-c-divider);
  background: var(--vp-c-bg-soft);
}

🔧 第七步:Markdown 扩展

7.1 自定义容器

::: tip 提示
这是一个提示信息
:::

::: warning 警告
这是一个警告信息
:::

::: danger 危险
这是一个危险警告
:::

::: details 点击查看详情
这是详细内容
:::

7.2 代码组

::: code-group

\`\`\`bash [pnpm]
pnpm add @gdu-common/ui
\`\`\`

\`\`\`bash [npm]
npm install @gdu-common/ui
\`\`\`

\`\`\`bash [yarn]
yarn add @gdu-common/ui
\`\`\`

:::

7.3 代码高亮行

// 高亮特定行
\`\`\`typescript {2,4-6}
function hello() {
  const name = 'world'  // [!code highlight]

  console.log('line 4')  // [!code highlight]
  console.log('line 5')  // [!code highlight]
  console.log('line 6')  // [!code highlight]
}
\`\`\`

// 标记添加/删除
\`\`\`typescript
function hello() {
  const name = 'world'   // [!code --]
  const name = 'Vue 3'   // [!code ++]
}
\`\`\`

📊 第八步:SEO 优化

8.1 Meta 标签配置

export default defineConfig({
  head: [
    // SEO
    ['meta', { name: 'keywords', content: 'Vue3, 组件库, TypeScript, Monorepo' }],
    ['meta', { name: 'author', content: 'GDU Team' }],

    // Open Graph
    ['meta', { property: 'og:type', content: 'website' }],
    ['meta', { property: 'og:title', content: 'GDU Common' }],
    ['meta', { property: 'og:description', content: '企业级前端组件库' }],
    ['meta', { property: 'og:image', content: '/og-image.png' }],

    // Favicon
    ['link', { rel: 'icon', href: '/favicon.ico' }],
    ['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }],
  ],

  // 最后更新时间
  lastUpdated: true,

  // 干净的 URL
  cleanUrls: true,
})

8.2 站点地图生成

// docs/.vitepress/config.ts
export default defineConfig({
  sitemap: {
    hostname: 'https://gdu-common.example.com',
  },
})

🚀 第九步:性能优化

9.1 构建优化

export default defineConfig({
  vite: {
    build: {
      // 代码压缩
      minify: 'terser',

      // 分包策略
      rollupOptions: {
        output: {
          manualChunks: {
            'vue-vendor': ['vue'],
            'vitepress-vendor': ['vitepress'],
          },
        },
      },

      // Chunk 大小警告
      chunkSizeWarningLimit: 1000,
    },
  },
})

9.2 图片优化

# 使用 webp 格式
docs/public/
├── logo.svg
├── hero.webp          # 首页图片
└── og-image.webp      # 社交分享图片

9.3 代码分割

// 异步加载组件
export default {
  async enhanceApp({ app }) {
    if (!import.meta.env.SSR) {
      const HeavyComponent = await import('./components/HeavyComponent.vue')
      app.component('HeavyComponent', HeavyComponent.default)
    }
  },
}

📦 第十步:部署配置

10.1 静态站点部署

export default defineConfig({
  base: '/', // 根目录部署
  // 或
  base: '/docs/', // 子目录部署

  outDir: '.vitepress/dist',
  cacheDir: '.vitepress/cache',
})

10.2 Nginx 配置

server {
    listen 80;
    server_name docs.gdu-common.com;
    root /var/www/docs/.vitepress/dist;
    index index.html;

    # SPA 路由支持
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

10.3 Docker 部署

# docs/Dockerfile
FROM node:20-alpine

WORKDIR /app

# 复制文件
COPY package.json pnpm-lock.yaml ./
COPY docs ./docs
COPY packages ./packages

# 安装依赖
RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile

# 构建文档
RUN pnpm --filter @gdu-common/docs build

# 使用 nginx 服务
FROM nginx:alpine
COPY --from=0 /app/docs/.vitepress/dist /usr/share/nginx/html
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

💡 最佳实践

1. 文档组织结构

docs/
├── guide/                 # 指南(给新手)
│   ├── index.md          # 介绍
│   ├── getting-started.md # 快速开始
│   └── installation.md   # 安装
├── components/            # 组件文档(给使用者)
│   ├── index.md
│   └── button.md
├── utils/                 # 工具文档
│   ├── index.md
│   └── common.md
└── advanced/              # 高级用法(给进阶用户)
    ├── customization.md
    └── theme.md

2. 示例代码规范

<!-- ✅ 好的示例 -->
<template>
  <!-- 清晰的HTML结构 -->
  <div class="demo">
    <Button @click="handleClick"> 点击次数:{{ count }} </Button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@gdu-common/ui'

// 简洁的逻辑
const count = ref(0)
const handleClick = () => {
  count.value++
}
</script>

<style scoped>
/* 必要的样式 */
.demo {
  padding: 20px;
}
</style>

3. 文档写作规范

# ✅ 清晰的标题层级

# 组件名

## 基础用法

### 示例标题

#### 细节说明

# ✅ 代码示例在前,文字说明在后

:::demo button/basic
:::

按钮组件支持多种类型...

# ✅ 完整的 API 文档

## Props

## Events

## Slots

## Methods

📈 效果展示

构建性能

# 文档构建速度
pnpm --filter @gdu-common/docs build

vitepress v1.6.4
✓ building client + server bundles...
✓ rendering pages...

build complete in 4.3s

访问性能

指标 数值 说明
FCP 0.8s 首次内容绘制
LCP 1.2s 最大内容绘制
TTI 1.5s 可交互时间
总分 95/100 Lighthouse 性能分数

用户体验

  • ✅ 本地搜索(无需服务器)
  • ✅ 暗黑模式切换
  • ✅ 响应式设计
  • ✅ 代码一键复制
  • ✅ 交互式示例

🤔 常见问题

Q1: 如何在文档中引用组件?

// 方式1:配置 alias
vite: {
  resolve: {
    alias: {
      '@gdu-common/ui': resolve(__dirname, '../../packages/ui/src')
    }
  }
}

// 方式2:直接导入
import { Button } from '@gdu-common/ui'

Q2: 如何实现组件的在线编辑?

// 集成 @vue/repl
import { Repl } from '@vue/repl'

// 提供在线编辑环境
<Repl :store="store" />

Q3: 如何自动生成 API 文档?

# 使用 vue-docgen-api
pnpm add -D vue-docgen-api

# 解析组件生成 JSON
# 渲染成 Markdown 表格

Q4: 文档部署后 404 怎么办?

// 配置正确的 base
export default defineConfig({
  base: '/', // 根目录
  // 或
  base: '/docs/', // 子目录

  cleanUrls: true, // 干净的 URL
})

🎁 完整示例

目录结构

docs/
├── .vitepress/
│   ├── config.ts
│   ├── theme/
│   │   ├── index.ts
│   │   ├── components/
│   │   │   ├── DemoBlock.vue
│   │   │   └── Playground.vue
│   │   └── custom.css
│   └── plugins/
│       └── demo-container.ts
├── components/
│   ├── button/
│   │   ├── index.md
│   │   ├── basic.vue
│   │   ├── types.vue
│   │   └── loading.vue
│   └── index.md
├── guide/
│   ├── index.md
│   └── getting-started.md
├── public/
│   ├── logo.svg
│   └── favicon.ico
├── index.md
└── package.json

🎉 总结

VitePress 文档站点的核心价值:

技术实现

  • ✅ VitePress 配置和自定义主题
  • ✅ 交互式组件示例
  • ✅ 自动代码提取和高亮
  • ✅ SEO 和性能优化

实际效果

  • 📖 专业级文档体验
  • ⚡ 4.3s 构建,1.2s 加载
  • 🎨 美观的 UI 设计
  • 📱 完美的响应式

关键配置

pnpm doc:dev     # 开发
pnpm doc:build   # 构建
pnpm doc:preview # 预览

下一篇文章,我将分享 CI/CD 自动化流程,包括:

  • GitLab CI 配置
  • 自动化测试
  • 自动化发布
  • Docker 镜像构建

🔗 系列文章


文档站点搭建完成!觉得有帮助的话点个赞支持一下! 👍

你的文档站点用的什么工具?有什么优化技巧?评论区交流! 💬

从零实现富文本编辑器#8-浏览器输入模式的非受控DOM行为

作者 WindRunnerMax
2025年10月20日 11:02

浏览器输入模式的非受控DOM行为

先前我们在选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,这是状态同步的重要实现之一。在这里我们要关注于处理浏览器复杂DOM结构默认行为,以及兼容IME输入法的各种输入场景,相当于我们来Case By Case地处理输入法和浏览器兼容的行为。

从零实现富文本编辑器项目的相关文章:

概述

在整个编辑器系列最开始的时候,我们就提到了ContentEditable的可控性以及浏览器兼容性问题,特别是结合了React作为视图层的模式下,状态管理以及DOM的行为将变得更不可控,这里回顾一下常见的浏览器的兼容性问题:

  • 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入<div><br></div>,而在FireFox(<60)中的表现是会插入<br>IE中的表现是会插入<p><br></p>
  • 在有文本的编辑器中,如果在文本中间插入回车例如123|123,在Chrome中的表现内容是123<div>123</div>,而在FireFox中的表现则是会将内容格式化为<div>123</div><div>123</div>
  • 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如123|123->123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为<div>123123</div>
  • 在同时存在两行文本的时候,如果同时选中两行内容再执行("formatBlock", false, "P")命令,在Chrome中的表现是会将两行内容包裹在同个<p>中,而在FireFox中的表现则是会将两行内容分别包裹<p>标签。
  • ...

由于我们的编辑器输入是依靠浏览器提供的组合事件,自然无法规避相关问题。编辑器设计的视图结构是需要严格控制的,这样我们才能根据一定的规则实现视图与选区模式的同步。依照整体MVC架构的设计,当前编辑器的视图结构设计如下:

<div data-block="true" >
  <div data-node="true">
    <span data-leaf="true"><span data-string="true">inline</span></span>
    <span data-leaf="true"><span data-string="true">inline2</span></span>
  </div>
</div>

那么如果在ContentEdiable输入时导致上述的结构被破坏,我们设计的编辑器同步模式便会出现问题。因此为了解决类似的问题,我们就需要实现脏DOM检查,若是出现破坏性的节点结构,就需要尝试修复DOM结构,甚至需要调度React来重新渲染严格的视图结构。

然而,如果每次输入或者选区变化等时机都进行DOM检查和修复,势必会影响编辑器整体性能或者输入流畅性,并且DOM检查和修复的范围也需要进行限制,否则同样影响性能。因此在这里我们需要对浏览器的输入模式进行归类,针对不同的类型进行不同的DOM检查和修复模式。

行内节点

DOM结构与Model结构的同步在非受控的React组件中变得复杂,这其实也就是部分编辑器选择自绘选区的原因之一,可以以此避免非受控问题。那么非受控的行为造成的主要问题可以比较容易地复现出来,假设此时存在两个节点,分别是inline类型和text类型的文本节点:

inline|text

此时我们的光标在inline后,假设schema中定义的inline规则是不会继承前个节点的格式,那么接下来如果我们输入内容例如1,此时文本就变成了inline|1text。这个操作是符合直觉的,然而当我们在上述的位置唤醒IME输入中文内容时,这里的文本就变成了错误的内容。

inline中文|中文text

这里的差异可以比较容易地看出来,如果是输入的英文或者数字,即不需要唤醒IME的受控输入模式,1这个字符是会添加到text文本节点前。而唤醒IME输入法的非受控输入模式,则会导致输入的内容不仅出现在text前,而且还会出现在inline节点的后面,这部分显然是有问题的。

这里究其原因还是在于非受控的IME问题,在输入英文时我们的输入在beforeinput事件中被阻止了默认行为,因此不会触发浏览器默认行为的DOM变更。然而当前在唤醒IME的情况下,DOM的变更行为是无法被阻止的,因此此时属于非受控的输入,这样就导致了问题。

此时由于浏览器的默认行为,inline节点的内容会被输入法插入“中文”的文本,这部分是浏览器对于输入法的默认处理。而当我们输入完成后,数据结构Model层的内容是会将文本放置于text前,这部分则是编辑器来控制的行为,这跟我们输入非中文的表现是一致的,也是符合预期表现的。

那么由于我们的immutable设计,再加上性能优化策略的memo以及useMemo的执行,即使在最终的文本节点渲染加入了脏DOM检测也是不够的,因为此时完全不会执行rerender。这就导致React原地复用了当前的DOM节点,因此造成了IME输入的DOM变更和Model层的不一致。

const onRef = (dom: HTMLSpanElement | null) => {
  if (props.children === dom.textContent) return void 0;
  const children = dom.childNodes;
  // If the text content is inconsistent due to the modification of the input
  // it needs to be corrected
  for (let i = 1; i < children.length; ++i) {
    const node = children[i];
    node && node.remove();
  }
  // Guaranteed to have only one text child
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = props.children;
  }
};

而如果我们直接将leafReact.memo以及useMemo移除,这个问题自然是会消失,然而这样就会导致编辑器的性能下降。因此我们就需要考虑尽可能检查到脏DOM的情况,实际上如果是在input事件或者MutationObserver中处理输入的纯非受控情况,也需要处理脏DOM的问题。

那么我们可以明显的想到,当行状态发生变更时,我们就直接检查当前行的所有leaf节点,然后对比文本内容,如果存在不一致的情况则直接进行修正。如果直接使用querySelector的话显然不够优雅,我们可以借助WeakMap来映射叶子状态到DOM结构,以此来快速定位到需要的节点。

然后在行节点的状态变更后,在处理副作用的时候检查脏DOM节点,并且由于我们的行状态也是immutable的,因此也不需要担心性能问题。此时检查的执行是O(N)的算法,而且检查的范围也会限制在发生rerender的行中,具体检查节点的方法自然也跟上述onRef一致。

const leaves = lineState.getLeaves();
for (const leaf of leaves) {
  const dom = LEAF_TO_TEXT.get(leaf);
  if (!dom) continue;
  const text = leaf.getText();
  // 避免 React 非受控与 IME 造成的 DOM 内容问题
  if (text === dom.textContent) continue;
  editor.logger.debug("Correct Text Node", dom);
  const nodes = dom.childNodes;
  for (let i = 1; i < nodes.length; ++i) {
    const node = nodes[i];
    node && node.remove();
  }
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = text;
  }
}

这里需要注意的是,脏节点的状态检查是需要在useLayoutEffect时机执行的,因为我们需要保证执行的顺序是先校正DOM再更新选区。如果反过来的话就会导致一个问题,先更新的选区依然停留在脏节点上,此时再校正会由于DOM节点变化导致选区的丢失,表现是选区会在inline的最前方。

leaf rerender -> line rerender -> line layout effect -> block layout effect

此外,这里的实现在首次渲染并不需要检查,此时不会存在脏节点的情况,因此初始化渲染的时候我们可以直接跳过检查。以这种策略来处理脏DOM的问题,还可以避免部分其他可能存在的问题,零宽字符文本的内容暂时先不处理,如果再碰到类似的情况是需要额外的检查的。

其实换个角度想,这里的问题也可能是我们的选区策略是尽可能偏左侧的查找,如果在这种情况将其校正到右侧节点可能也可以解决问题。不过因为在空行的情况下我们的末尾\n节点并不会渲染,因此这样的策略目前并不能彻底解决问题,而且这个处理方式也会使得编辑器的选区策略变得更加复杂。

[inline|][text] => [inline][|text]

这里还需要关注下ReactHooks调用时机,在下面的例子中,从真实DOM中得到onRef执行顺序是最前的,因此在此时进行首次DOM检查是合理的。而后续的Child LayoutEffect就类似于行DOM检查,在修正过后在Parent LayoutEffect中更新选区是符合调度时机方案。

Child onRef
Child useLayoutEffect
Parent useLayoutEffect
Child useEffect
Parent useEffect
// https://playcode.io/react
import React from 'react';
const Child = () => {
  const [,forceUpdate] = React.useState({});
  const onRef = () => console.log("Child onRef");
  React.useEffect(() => console.log("Child useEffect"));
  React.useLayoutEffect(() => console.log("Child useLayoutEffect"));
  return <button ref={onRef} onClick={() => forceUpdate({})}>Update</button>
}
export function App(props) {
  React.useEffect(() => console.log("Parent useEffect"));
  React.useLayoutEffect(() => console.log("Parent useLayoutEffect"));
  return <Child></Child>;
}

包装节点

关于包装节点的问题需要我们先聊一下这个模式的设计,现在实现的富文本编辑器是没有块结构的,因此实现任何具有嵌套的结构都是个复杂的问题。在这里我们原本就不会处理诸如表格类的嵌套结构,但是例如blockquote这种wrapper级结构我们是需要处理的。

类似的结构还有list,但是list我们可以完全自己绘制,但是blockquote这种结构是需要具体组合才可以的。然而如果仅仅是blockquote还好,在inline节点上使用wrapper是更常见的实现,例如a标签的包装在编辑器的实现模式中就是很常规的行为。

具体来说,在我们将文本分割为bolditalicinline节点时,会导致DOM节点被实际切割,此时如果嵌套<a>节点的话,就会导致hover后下划线等效果出现切割。因此如果能够将其wrapper在同一个<a>标签的话,就不会出现这种问题。

但是新的问题又来了,如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个需要wrapperkey则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

思来想去,我最终想到了个简单的实现,对于需要wrapper的元素,如果其合并listkeyvalue全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890

不过话又说回来,这种wrapper结构是比较特殊的场景下才会需要的,在某些操作例如缩进这个行为中,是无法判断究竟是要缩进引用块还是缩进其中的文字。这个问题在很多开源编辑器中都存在,特别是扁平化的数据结构设计例如Quill编辑器。

其实也就是在没有块结构的情况下,对于类似的行为不好控制,而整体缩进这件事配合list在大型文档中也是很合理的行为,因此这部分实现还是要等我们的块结构编辑器实现才可以。当然,如果数据结构本身支持嵌套模式,例如Slate就可以实现。

后续在wrap node实现的a标签来实现输入时,又出现了上述类似inline-code的脏DOM问题。以下面的DOM结构来看,看似并不会有什么问题,然而当光标放置于超链接这三个字后唤醒IME输入中文时,会发现输入“测试输入”这几个字会被放置于直属div下,与a标签平级。

<div contenteditable>
  <a href="https://www.baidu.com"><span>超链接</span></a>
  <span>文本</span>
</div>
<div contenteditable>
  <a href="https://www.baidu.com"><span>超链接</span></a>
  测试输入
  <span>文本</span>
</div>

在这种情况下我们先前实现的脏DOM检测就失效了,因为检查脏DOM的实现是基于data-leaf实现的。此时浏览器的输入表现会导致我们无法正确检查到这部分内容,除非直接拿data-node行节点来直接判断,这样的实现自然不够好。

说到这里,先前我发现飞书文档的实现是a标签渲染的leaf,而wrap的包装实现是使用的span直接处理的,并且额外增加了样式来实现hover效果。直接使用span包裹就不会出现上述问题,而内部的a标签虽然会导致同样的问题,但是在leaf下可以触发脏DOM检查。

<div contenteditable>
  <span>
    <a href="https://www.baidu.com"><span>超链接</span></a>
    测试输入
  </span>
  <span>文本</span>
</div>

因此就可以在先前的脏DOM检查基础上解决了问题,而本质上类似的行为就是浏览器默认处理的结果,不同的浏览器处理结果可能都不一样。目前看起来是浏览器认为a标签的结构应该是属于inline的实现,也就是类似我们的inline-code实现,理论上倒却是并没有什么问题,由此我们需要自己来处理这些非受控的问题。

实际上Quill本身也会出现这个问题,同样也是脏DOM的处理。而slate并不会出现这个问题,这里处理方案则是通过DOM规避了问题,在a标签两端放置额外的&nbsp节点,以此来避免这个问题。当然还引入了额外的问题,引入了新的节点,目前看起来转移光标需要受控处理。

<!-- https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/inlines.tsx -->
<div contenteditable>
  <a href="https://www.baidu.com"
    ><span contenteditable="false" style="font-size: 0">&nbsp;</span
    ><span>超链接测试输入</span
    ><span contenteditable="false" style="font-size: 0">&nbsp;</span></a
  ><span>文本</span>
</div>

浏览器兼容性

在后续浏览器的测试中,重新出现了上述提到的a标签问题,此时并不是由于包装节点引起的,因此问题变得复杂了很多,主要是各个浏览器的兼容性的问题。类似于行内代码块,本质上还是浏览器IME非受控导致的DOM变更问题,但是在浏览器表现差异很大,下面是最小的DEMO结构。

<div contenteditable>
  <span data-leaf><a href="#"><span data-string>在[:]后输入:</span></a></span><span data-leaf>非链接文本</span>
</div>

在上述示例的a标签位置的最后的位置上输入内容,主流的浏览器的表现是有差异的,甚至在不同版本的浏览器上表现还不一致:

  • Chrome中会在a标签的同级位置插入文本类型的节点,效果类似于<a></a>"text"内容。
  • Firefox中会在a标签内插入span类型的节点,效果类似于<a></a><span data-string>text</span>内容。
  • Safari中会将a标签和span标签交换位置,然后在a标签上同级位置加入文本内容,类似<span><a></a>"text"</span>
<!-- Chrome -->
<span data-leaf="true">
  <a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
  "文本"
</span>

<!-- Firefox -->
 <span data-leaf="true">
  <a href="https://www.baidu.com"><span data-string="true">超链接</span></a>
  <span data-string="true">文本</span>
</span>

<!-- Safari -->
 <span data-leaf="true">
  <span data-string="true">
    <a href="https://www.baidu.com">超链接</a>
    "文本"
    ""
  </span>
</span>

因此我们的脏DOM检查需要更细粒度地处理,仅仅对比文本内容显然是不足以处理的,我们还需要检查文本的内容节点结构是否准确。其实最开始我们是仅处理了Chrome下的情况,最简单的办法就是在leaf节点下仅允许存在单个节点,存在多个节点则说明是脏DOM

for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  node && node.remove();
}

但是后来发现在编辑时会把Embed节点移除,这里也就是因为我们错误地把组合的div节点当成了脏DOM,因此这里就需要更细粒度地处理了。然后考虑检查节点的类型,如果是文本的节点类型再移除,那么就可以避免Embed节点被误删的问题。

for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  isDOMText(node) && node.remove();
}

虽然看起来是解决了问题,然而在后续就发现了FirefoxSafari下的问题。先来看Firefox的情况,这个节点并非文本类型的节点,在脏DOM检查的时候就无法被移除掉,这依然无法处理Firefox下的脏DOM问题,因此我们需要进一步处理不同类型的节点。

// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  // 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
  if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
    continue;
  }
  node.remove();
}

Safari的情况下就更加复杂,因为其会将a标签和span标签交换位置,这样就导致了DOM结构性造成了破坏。这种情况下我们就必须要重新刷新DOM结构,这种情况下就需要更加复杂地处理,在这里我们加入forceUpdate以及TextNode节点的检查。

其实在飞书文档中也是采用了类似的做法,飞书文档的a标签在唤醒IME输入后,同样会触发脏DOM的检查,然后飞书文档会直接以行为基础ReMount当前行的所有leaf节点,这样就可以避免复杂的脏DOM检查。我们这里实现更精细的leaf处理,主要是避免不必要的挂载。

const LeafView: FC = () => {
  const { forceUpdate, index: renderKey } = useForceUpdate();
  LEAF_TO_REMOUNT.set(leafState, forceUpdate);
  return (<span key={renderKey}></span>);
}

if (isDOMText(dom.firstChild)) {
  // ...
} else {
  const func = LEAF_TO_REMOUNT.get(leaf);
  func && func();
}

这里需要注意的是,我们还需要处理零宽字符类型的情况。当Embed节点前没有任何节点,即位于行首时,输入中文后同样会导致IME的输入内容被滞留在Embed节点的零宽字符上,这点与上述的inline节点是类似的,因此这部分也需要处理。

const zeroNode = LEAF_TO_ZERO_TEXT.get(leaf);
const isZeroNode = !!zeroNode;
const textNode = isZeroNode ? zeroNode : LEAF_TO_TEXT.get(leaf);
const text = isZeroNode ? ZERO_SYMBOL : leaf.getText();
const nodes = textNode.childNodes;

到这里,我们的脏DOM检查已经能够处理大部分情况了,整体的模式都是React在行DOM结构计算完成后,浏览器渲染前进行处理。针对于文本节点以及a标签的检查,需要检查文本与状态的关系,以及严格的DOM结构破坏后的需要直接Remount组件。

// 文本节点内部仅应该存在一个文本节点, 需要移除额外节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  node && node.remove();
}
// 如果文本内容不合法, 通常是由于输入的脏 DOM, 需要纠正内容
if (isDOMText(textNode.firstChild)) {
  // Case1: [inline-code][caret][text] IME 会导致模型/文本差异
  // Case3: 在单行仅存在 Embed 节点时, 在节点最前输入会导致内容重复
  if (textNode.firstChild.nodeValue === text) return false;
  textNode.firstChild.nodeValue = text;
  } else {
  // Case2: Safari 下在 a 节点末尾输入时, 会导致节点内外层交换
  const func = LEAF_TO_REMOUNT.get(leaf);
  func && func();
  if (process.env.NODE_ENV === "development") {
    console.log("Force Render Text Node", textNode);
  }
}

而针对于额外的文本节点,即本章节中重点提到的浏览器兼容性问题,我们需要严格地控制leaf节点下的DOM结构。如果仅存在单个文本节点的情况下,是符合设计的结构,而如果是存在多个节点,除了Void/Embed节点的情况外,则说明DOM结构被破坏了,这里我们就需要移除掉多余的节点。

// data-leaf 节点内部仅应该存在非文本节点, 文本类型单节点, 嵌入类型双节点
for (let i = 1; i < nodes.length; ++i) {
  const node = nodes[i];
  // 双节点情况下, 即 Void/Embed 节点类型时需要忽略该节点
  if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
    continue;
  }
  // Case1: Chrome a 标签内的 IME 输入会导致同级的额外文本节点类型插入
  // Case2: Firefox a 标签内的 IME 输入会导致同级的额外 data-string 节点类型插入
  node.remove();
}

样式组合渲染

由于我们的编辑器是以immutable提高渲染性能,因此在文本节点变更时若是需要存在连续的格式处理,例如inline-code的样式实现,就会出现组件不重新渲染问题。具体表现是若是存在多个连续的code节点,最后一个节点长度为1,删除最后这个节点时会导致前一个节点无法刷新样式。

[inline][c]|

这个问题的原因是我们的className是在渲染leaf节点时动态计算的,具体的逻辑如下所示。如果前一个节点不存在或者前一个节点不是inline-code,则添加inline-code-start类属性,类似的需要在最后一个节点加入inline-code-end类属性。

if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
  context.classList.push(INLINE_CODE_START_CLASS);
}
context.classList.push("block-kit-inline-code");
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
  context.classList.push(INLINE_CODE_END_CLASS);
}

这个情况同样类似于Dirty DOM的问题,由于删除的节点长度为1,因此前一个节点的LeafState并没有变更,因此不会触发React的重新渲染。这里我们就需要在行节点渲染时进行纠正,这里的执行倒是不需要像上述检查那样同步执行,以异步的effect执行即可。

/**
 * 编辑器行结构布局计算后异步调用
 */
public didPaintLineState(lineState: LineState): void {
  for (let i = 0; i < leaves.length; i++) {
    if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_START_CLASS);
    }
    if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_END_CLASS);
    }
  }
}

虽然看起来已经解决了问题,然而在React中还是存在一些问题,主要的原因此时的DOM处理是非受控的。类似于下面的例子,由于React在处理style属性时,只会更新发生变化的样式属性,即使整体是新对象,但具体值与上次渲染时相同,因此React不会重新设置这个样式属性。

// https://playcode.io/react
import React from "react";
export function App() {
  const el = React.useRef();
  const [, setState] = React.useState(1);
  const onClick = () => {
    el.current && (el.current.style.color = "blue");
  }
  console.log("Render App")
  return (
    <div>
      <div style={{ color:"red" }} ref={el}>Hello React.</div>
      <button onClick={onClick}>Color Button</button>
      <button onClick={() => setState(c => ++c)}>Rerender Button</button>
    </div>
  );
}

因此,在上述的didPaintLineState中我们主要是classList添加类属性值,即使是LeafState发生了变更,React也不会重新设置类属性值,因此这里我们还需要在didPaintLineState变更时删除非必要的类属性值。

public didPaintLineState(lineState: LineState): void {
  for (let i = 0; i < leaves.length; i++) {
    if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_START_CLASS);
    } else {
      node && node.classList.remove(INLINE_CODE_START_CLASS);
    }
    if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
      node && node.classList.add(INLINE_CODE_END_CLASS);
    } else {
      node && node.classList.remove(INLINE_CODE_END_CLASS);
    }
  }
}

总结

在先前我们实现了半受控的输入模式,这个输入模式同样是目前大多数富文本编辑器的主流实现方式。在这里我们关注于浏览器ContentEdiable模式输入的默认行为造成的DOM结构问题,并且通过脏DOM检查的方式来修正这些问题,以此来保持编辑器的严格DOM结构。

当前我们主要关注的是编辑器文本的输入问题,即如何将键盘输入的内容写入到编辑器数据模型中。而接下来我们需要关注于输入模式结构化变更的受控处理,即回车、删除、拖拽等操作的处理,这些操作同样也是基于输入相关事件实现的,而且通常会涉及到文本的结构变更,属于输入模式的补充。

每日一题

参考

fluth-vue: 体验流式编程范式之美

作者 yiludegeX
2025年10月17日 11:12

感受流式编程范式在代码组织、代码维护、代码调试、页面渲染方面的全新体验

背景

在 如何处理复杂前端业务代码 文章中简单的介绍了流在异步数据依赖关系维护和异步逻辑依赖关系维护的一些应用,本篇文章详细的介绍在 vue 响应式框架中如何将流这种全新的编程范式无缝的融入,并阐述了通过流式编程这种全新编程维度在代码组织、代码维护、代码调试、页面渲染方面带来的全新体验。

当前 vue 开发的痛点

响应式数据开发调试痛点

vue 的响应式数据用在 template 绑定上体验感非常好,深层的数据修改后可以立马触发组件的更新;但是响应式数据在逻辑层面体验就比较糟糕:

  1. 响应式数据是mutable的,所以想知道数据的 previous 状态是非常困难的;
  2. watch 响应式数据,然后做逻辑处理,首先没有语义,其次实现复杂控制标记位过多;
  3. 对于复杂对象常常很难找出在逻辑中哪个响应式的属性的修改以及代码的哪个位置修改导致组件更新;

用响应式的数据来组织逻辑虽然看上去效率很高,但是在代码阅读和代码维护以及debug方面常常带来很大的困扰。

代码组织维护痛点

写 vue 业务组件或者逻辑的时候,只要稍不注意代码体积就在膨胀;哪怕采用了 hook 编程理念,膨胀依旧发生在hook。这个膨胀体现在:函数处理的场景越来越多,入参就要越来越多,函数体里面 if-else 也就越来越多;似乎只有反复拆解重构才能达到一个比较好的平衡,对心智负担和时间成本消耗都比较大。

复杂度不会消失,只能转移或者隔离;需要成本更低的代码组织形式来对抗业务的复杂度。

流式编程开发的痛点

rxjs 是一个大家都熟悉的响应式编程库,内置了大量的操作符并通过pipe这种管道的形式进行串联,让数据在管道内通过操作符进行处理和流转;在这里将这种通过管道的形式来对数据进行响应式的编程方式叫做流式编程,那么rxjs就是一个流式编程范式的库,流式编程范式具备如下优点:

  1. 响应式:流可以不断触发也可以被订阅;
  2. 管道式:任何一个环节都处于管道之中,上游一目了然;
  3. 声明式:管道内的操作可以内聚成操作符,大大提升代码的表达力和简洁性;

但是流式编程长期以来被认为是“牛刀”,似乎只有复杂的异步数据源场景才配用上,这里面很大归功于 rxjs 较高的上手门槛和繁多的概念。

响应式和流的结合

如果可以简化流式编程的使用成本,采取数据的响应式来更新视图,流的响应式来组织业务逻辑代码代码,结合响应式数据带来的页面自动更新的便捷和流式编程带来的声明式逻辑丝滑处理,是否可以解决上面的痛点并给前端开发带来效率上的提升呢?

下面介绍 fluth-vue 结合数据响应式和流式编程,在开发调试、代码组织、页面渲染等方面发生的奇妙的化学反应。

Promise流

fluth

fluth (/fluːθ/) 由 flux + then 两个单词组合而来,代表类似 promise 的流。Promise 是前端最常接触的异步流式编程范式,类 Promise 的流式编程范式极大地降低了流式编程的门槛,

认为 promise 是发布者而 then 方法是订阅者,promise 的发布行为则只有一次。fluth 加强了 promise,让 promise 可以不断的发布!如果你熟悉 Promise,那么你已经掌握了 fluth 的基础。

以下面代码为例:

import { $ } from 'fluth'

const promise$ = $(
promise$.then(
  (r) => console.log('resolve', r),
  (e) => console.log('reject', e)
)
promise$.next(1)
promise$.next(2)
promise$.next(Promise.reject(3))
console.log('end')

// 打印:
// resolve 1
// resolve 2
// end
// reject 3

但是 fluth 相比 promise 有如下差异点:

  1. 相比 promise,fluth 可以不断发布并且支持取消定订阅
  2. 相比 promise,fluth 同步执行 then 方法,及时更新数据
  3. 相比 promise,fluth 完全支持 PromiseLike

还有一个重要的差异点:fluth 保留每个订阅节点的数据供后续使用

以下面代码为例:

import { $ } from 'fluth'

const promise$ = $(0)
const observable$ = promise$.thenImmediate(v => v + 1)

promise$.value === 0 ✅
observable$.value === 1 ✅

promise$.next(1)

promise$.value === 1 ✅
observable$.value === 2

流上面的每一个子节点,返回的值都可以通过 value 属性来获得,这样每个节点既可以进行逻辑处理又能保留处理后的数据。

相比 rxjs 这样非常成熟的流式编程库,和 fluth 相比而言有几个区别

  1. fluth 上手非常简单,是类 promise 的流式编程库,只要会使用 promise 就可以使用
  2. fluth 的流是 hot、multicast 的,而 rxjs 的流还具备 cold、unicast 的特性
  3. fluth 可以流链式订阅,而 rxjs 的订阅后无法再链式订阅
  4. fluth 保留了每个订阅节点的数据以及状态供后续消费
  5. fluth 订阅节点存在和 promise 类似的 status 状态
  6. fluth 可以添加插件来扩展流的功能和添加自定义行为

如下代码所示:

// rxjs:
stream$.pipe(operator1, operator2, operator3)
stream$.subscribe(observer1)
stream$.subscribe(observer2)
stream$.subscribe(observer3)

//fluth:
stream$.use(plugin1, plugin2, plugin3)
stream$
  .pipe(operator1, operator2)
  .then(observer1)
  .pipe(operator3)
  .then(observer2)
  .pipe(operator4)
  .then(observer3);
stream$.next(1);

fluth-vue

fluth-vue 则进一步将响应式 + 流式编程完美融合;让流可以成为替代 ref、reactive 响应式数据的基础单元。

以下面代码为例:

import { $, filter, debounce } from "fluth-vue";
import { ref } from "vue";

const data = ref({name: 'xxx', age: '18'})
const data$ = $({name: 'xxx', age: '18'})

data$.pipe(
 debounce(300),
 filter((v) => v.age > 18),
 map((v) => ({name: v.name, age: v.age + 1}))
)

data 和 data$ 在响应式方面几乎完全一致,但是 data$ 却比 data 多了一个全新的流式编程的维度。

响应式能力

响应式数据

对于 fluth-vue 来说,和 ref 数据在响应式方面几乎完全一致体现在:

  1. 可以正常的 watch、computed
  2. 在 template 中可以正常的被解包,不需要使用 .value 
  3. 在 vue-devtools 中可以正常显示其值

如下所示,除了修改数据,$("fluth") 和 ref("fluth") 两者完全等价。

<template>
  <div>
    <p>{{ name$ }}</p>
  </div>
</template>

<script setup>
import { watch, computed } from "vue"
import { $ } from "fluth-vue";

const name$ = $("fluth");

const computed = computed(() => name$.value);

watch(name$, (value) => {
 console.log(value);
}); 
</script>

响应式更新

唯一的差异点在于:修改数据必须采用 fluth next 或 set 方法

import { $ } from "fluth-vue";

const stream$ = $({ obj: { name: "fluth", age: 0 } });

stream$.set((value) => (value.obj.age += 1));

通过 next 和 set 修改数据后,不但会触发 vue 响应式的更新;还会触发流的推流,所有订阅节点都将得到数据的推送。

不可变数据能力

在修改数据方面存在差异点的原因是 fluth 底层采用了 immutable 的数据存储

流的数据在流转的过程中被每个节点处理后再把处理结果给到下一个节点,而每个节点都需要保留处理后的数据,通过数据的 immutable 来保证数据之间隔离,让每个节点都能拥有不被污染的数据。fluth提供了setthenSetthenImmediateSetthenOnceSet方法和 set 操作符来对节点进行 immutable 处理。

数据和响应式解耦

使用 ref 或者 reactive 的时候,数据和响应式是一体的,修改了数据就会触发响应式,但是流可以做到数据和响应式的解耦

如下所示:

const wineList$ = $(["Red Wine", "White Wine", "Sparkling Wine", "Rosé Wine"]);

const age$ = $(0);
const availableWineList$ = age$
  .pipe(filter((age) => age > 18))
  .then(() => wineList.value);

只有 age$ 大于 18 的时候,才可以获取到 wineList$ 的最新值,但是后续 wineList$ 的 immutable 修改不会触发 availableWineList$ 的重新计算以及值的变化,只有 age$ 的变化才会触发 availableWineList$ 的重新取值,如果采用 vue computed 的方式进行运算,不管是 age 还是 wineList 的变化都会引起 availableWineList 的计算。

调试能力

fluth 提供了丰富的调试插件:

打印插件

通过consoleNode 插件可以方便的打印流节点数据

import { $, consoleNode } from "fluth-vue";

const data$ = $().use(consoleNode());

data$.next(1); // 打印 resolve 1
data$.next(2); // 打印 resolve 2
data$.next(3); // 打印 resolve 3

data$.next(Promise.reject(4)); // 打印 reject 4

由于 fluth-vue 底层采用 immutable 的数据,对于复杂对象使用打印插件可以保留每个修改时刻的快照供调试,而 ref 数据要做到则需要采用 JSON.stringify。

通过consoleAll 插件可以方便的查看流所有的节点数据

import { $, consoleAll } from "fluth-vue";

const data$ = $().use(consoleAll());
data$
  .pipe(debounce(300))
  .then((value) => {
    throw new Error(value + 1);
  })
  .then(undefined, (error) => ({ current: error.message }));

data$.next(1)
// 打印 resolve 1
// 打印 reject Error: 2
// 打印 resolve {current: '2'}

断点插件

通过debugNode插件可以方便的调试流节点数据,并可以查看流节点的调用栈

import { $, debugNode } from "fluth-vue";

const stream$ = $(0);

stream$.then((value) => value + 1).use(debugNode());

stream$.next(1);
// 触发调试器断点

条件调试

import { $ } from "fluth-vue";
import { debugAll } from "fluth-vue";

// 只对字符串类型触发调试器
const conditionFn = (value) => typeof value === "string";
const stream$ = $().use(debugNode(conditionFn));

stream$.next("hello"); // 触发调试器
stream$.next(42); // 不触发调试器

通过debugAll插件可以方便的调试流所有的节点数据,并可以查看流节点的调用栈,可以非常容易的找到数据修改的属性和位置。

import { $, debugAll } from "fluth-vue";

const data$ = $().use(debugAll());

data$.then((value) => value + 1).then((value) => value + 1);

const updateData$ = () => {
  data$.next(data$.value + 1);
};
// 在浏览器开发者工具中会在每个节点触发调试器断点
// 当前有三个节点,所以会触发三次断点

打印和调试插件的出现彻底的改变了以前调试 vue 复杂对象的体验。

异步能力

fluth-vue 提供了强大的异步处理能力,体现在下面两个方面:

更强大的promsie

fluth 流的每个节点都实现了 promise 的全套能力:then、catch、finally,与此同时还实现了then节点的同步运行:

Promise.resolve(1).then(v=> console.log(v));
console.log('hello');

// hello
// 1

const stream$ = $()
stream$.then(v => console.log(v));
stream$.next(1)
console.log('hello') 
// 1 
// hello

fluth 流如果节点都是同步操作,可以看到都是同步执行。同步执行对于前端非常重要,如果每个节点都异步执行那么会导致页面反复的渲染。

还支持对 then 进行取消订阅

import { $, consoleAll } from "fluth-vue";

const stream$ = $().use(consoleAll());
const observable$ = stream$.then( v =>  v + 1)

stream$.next(1)
//resolve 1
//resolve 2

observable$.unsubscribe() // 取消订阅
stream$.next(1)
//resolve 1

 由于 fluth 可以链式的进行订阅,而订阅的节点可能是异步节点,异步节点返回的时间是不确定的。当异步节点还没返回,如果此时流又推送了新的数据到节点则会产生异步竞态问题,fluth 解决了异步竞态的问题

const stream$ = $()

stream$
  .then(x => x+1)
  .then(x => new Promise(resolve => settimeout(() => resolve(x), 50)))
  .then(x => x*2)

stream$.next(1)
sleep(30)
stream$.next(2)
sleep(60)
stream$.next(3)
sleep(30)
stream$.next(4)

 第一个数据和第三个数据由于竞态问题会在节点处理中丢弃掉,如下图所示:

image.png

流式的api

fluth-vue 提供 useFetch 函数,让 api 的请求能够支持流,这样可以将 api 的请求作为流的一个节点看待。

const url$ = $("https://api.example.com/data");
const payload$ = $({ id: 1, name: "fluth" });
const { promise$ } = useFetch(url$, { immediate: false, refetch: true })
  .get(payload$)
  .json();

promise$.then((data) => {
  console.log(data); // api 请求的结果
});

url$.next("https://api.example.com/data2"); // 触发请求,并打印结果
payload$.next({ id: 2, name: "vue" }); // 触发请求,并打印结果

image.png 这样,不管是 url$ 还是 payload$ 发起推流,都会重新发起请求并通过 promise$ 进行推流给到下游进行消费。

流式渲染能力

fluth-vue流的数据就是响应式数据可以正常在 template 中渲染,除此之外 fluth-vue 还提供了强大的流式渲染render$功能,可以实现元素级渲染或者块级渲染,整体效果类似 signal 或者 block signal 的渲染。

元素级渲染

import { defineComponent, onUpdated } from "vue";
import { $, effect$ } from "fluth-vue";

export default defineComponent(
  () => {
    const name$ = $("hello");

    onUpdated(() => {
      console.log("Example 组件更新");
    });

    return effect$(() => (
      <div>
        <div>
          名字:{name$.render$()}
        </div>
        <button onClick={() => name$.set((v) => v + " world")}>更新</button>
      </div>
    );
  },
  {
    name: "Example",
  },
);

 点击按钮只会修改 div 元素下的 name$.render$() 内容,不触发组件 onUpdated 生命周期。

块级渲染

import { defineComponent, onUpdated, h } from "vue";
import { $, effect$ } from "fluth-vue";

export default defineComponent(
  () => {
    const user$ = $({ name: "", age: 0, address: "" });
    const order$ = $({ item: "", price: 0, count: 0 });

    return effect$(() => (
      <div class="card-light">
        <div> example component </div>
        <div>render time: {Date.now()}</div>
        <section style={{ display: "flex", justifyContent: "space-between" }}>
          {/* use$ emit data only trigger render content update*/}
          {user$.render$((v) => (
            <div key={Date.now()} class="card">
              <div>user$ render</div>
              <div>name:{v.name}</div>
              <div>age:{v.age}</div>
              <div>address:{v.address}</div>
              <div>render time: {Date.now()}</div>
            </div>
          ))}
          {/* order$ emit data only trigger render content update*/}
          {order$.render$((v) => (
            <div key={Date.now()} class="card">
              <div>order$ render</div>
              <div>item:{v.item}</div>
              <div>price:{v.price}</div>
              <div>count:{v.count}</div>
              <div>render time: {Date.now()}</div>
            </div>
          ))}
        </section>

        <div class="operator">
          <button class="button" onClick={() => user$.set((v) => (v.age += 1))}>
            update user$ age
          </button>
          <button
            class="button"
            onClick={() => order$.set((v) => (v.count += 1))}
          >
            update order$ count
          </button>
        </div>
      </div>
    ));
  },
  {
    name: "streamingRender",
  },
);

use$ 或者 order$ 流更新后,只会更新 render$ 函数里面的内容,不会引起组件的虚拟 dom diff 以及 update 的生命周期。

一旦流可以掌控渲染,那么可以做的事情就非常多了,比如 user$.pipe(debounce(300)).render$ 😋,这里就不进一步展开了。

代码组织能力

流这种编程范式和前端业务模型高度匹配在代码组织上表现的尤为明显。

下面以一个简单的例子——订单表单的提交页面,来展示流在业务模型中的应用:

image.png 传统的前端开发采用命令式编程模式

  • 点击按钮后,调用 handleSubmit 方法
  • handleSubmit 先 validateForm 方法,如果验证不通过,则提示报错
  • 验证通过拼装后台需要的数据
  • 调用后台 fetchAddOrderApi 方法
  • 如果调用成功,则继续调用 handleDataB 方法、handleDataC 方法
  • 如果调用失败,则提示报错

这应该是大部分前端开发者的日常,开发日常不代表天经地义,这种命令式开发模式、夹杂同步逻辑异步操作,随着业务复杂度增长,handleSubmit 方法会变得越来越臃肿,也将变得越来越难以复用

下面采用流的声明式编程方式重新实现:

image.png 按照业务逻辑,代码实现为六条流:form$、trigger$、submit$、validate$、payload$、addOrderApi$每一条流都承载着独立的逻辑,流的先后顺序按照业务真实顺序进行组织form$、trigger$ 负责将用户的输入转换为流,validate$、addOrderApi$ 则将流的处理结果传递用户。

通过代码可以发现:

  • 复用性提升,采用流式编程范式后逻辑充分的原子化了,而流既可以分流又可以合流可以轻易的对这些逻辑原子进行逻辑组合,代码的复用性空前的提高
  • 维护性提升,代码从上到下是按照业务真实顺序进行组织的,当前只有一个 handleSubmit 方法可能还不明显,当业务逻辑复杂后,按照业务事实顺序组织代码将对阅读性、维护性有极大的提升
  • 表达力提升auditdebouncefilter等操作符以声明式的方式处理了触发器、节流、条件过滤等复杂的异步控制逻辑,通过流的操作符,代码的表达力显著提升。
  • 控制反转,相对于方法调用这种”拉“的方式,流式编程范式是”推“的方式,可以实现数据、修改数据的方法、触发数据修改的行为都放置在同一个文件夹内,再也无需全局搜索哪里的调用改变了模块内部的数据。

复用性和可维护性优势

对于命令式的编程,在 handleSubmit 后续的迭代中可能需要分场景:

  • 场景 A 调用 fetchAddOrderApi 成功后只需要调用 handleDataB 方法
  • 场景 B 调用 fetchAddOrderApi 成功后只需要调用 handleDataC 方法

此时 handleSubmit 只能将场景变为参数交由 if - else 来处理,随着越来越多的分支逻辑,函数逐渐膨胀。如果用流式编程范式来实现,这个问题可以轻松解决:

  • 如果场景是流的话,通过组合流就可以轻松解决
// 场景 A 流
const caseA$ = $();
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);

// 场景 B 流
const caseB$ = $();
addOrderApi$.pipe(audit(caseB$)).then(handleDataC);
  • 如果场景是数据的话,既可以通过分流也可以通过过滤来处理,两种方式都可以轻松解决
// 场景流,可能是 A,也可能是 B
const case$ = $<"A" | "B">();

// 方法1: 分流
const [caseA$, caseB$] = partition(case$, (value) => value === "A");
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);
addOrderApi$.pipe(audit(caseA$)).then(handleDataC);

// 方法2: 过滤
const caseAA$ = addOrderApi$
  .pipe(filter(case$.value === "A"))
  .then(handleDataB);

const caseBB$ = addOrderApi$
  .pipe(filter(case$.value === "B"))
  .then(handleDataC);

代码逻辑原子化以及流的分流和合流让 fluth-vue 在代码组织能力上如鱼得水。

重构优势

上面是一个简单的示例,如果业务逻辑复杂传统开发模式下,一个 setup 函数下面可能有十几个 ref 和几十个 methods,如果认为 setup 是一个 class,那么这个 class 将拥有十几属性和几十个方法以及的坏味道的 watch “打洞”逻辑,阅读和维护成本将非常的高。

虽然更小粒度的的抽离组件以及 hooks 的开发理念可以解决部分问题,但现实是当前大量现存业务仍然是由很多这样臃肿的 setup 函数构造的组件组装的,因为种种原因一旦 setup 成为这个臃肿的 class,那么后续的开发者只能在这个 setup 上持续“深耕”。

而流式编程范式可以很好的解决这个问题,如果一开始采用 fluth-vue 开发业务,随着业务持续迭代,代码也会也来也长;但是流式编程是按照业务真实顺序进行声明式组织代码,相当于一条线不断延伸,此时要抽离逻辑只需要将线剪成几段分别放入 hook 就好了,完全没有心智负担,相当于有一个很重的业务,只需要几分钟就可以解决重构好。

总结

通过在实际业务中用流式编程范式进行开发和调试,发现流这种编程范式在前端领域被严重的低估,可能是 rxjs 概念或者使用较为复杂让大家认为是一把牛刀,只有复杂异步数据流组合场景才配用上,其实最简单的 ref("字符串"),当采用 $("字符串")后都能带来非常可观的收益。

fluth-vue 真正意义上将流式编程范式带给了vue开发者:让流成为前端最基础的数据形态并完美兼容响应式,将响应式进行彻底:除了数据和视图的响应式,逻辑也能用流响应式的组织。

实际体验下来的感受:流式编程范式与前端业务的异步、事件驱动特性天然契合,是组织前端业务逻辑的理想选择

最后项目已开源🎉🎉🎉,欢迎 star ⭐️⭐️⭐️ !!!

github.com/fluthjs/flu…

github.com/fluthjs/flu…

🔥开源零配置!10 分钟上手:create-uni + uView Pro 快速搭建企业级 uni-app 项目

2025年10月14日 13:30

推荐阅读:

🔥 uView Pro 正式开源!70+ Vue3 组件重构完成,uni-app 组件库新晋之星

本文面向希望快速搭建 uni-app 项目的开发者与团队,介绍如何使用 create-uni 脚手架一键创建项目,如何在项目中引入并配置 uView Pro 组件库,以及如何利用 uni-helper 系列插件(vite-plugin、unocss 等)提高开发效率。

一、为什么选择 create-uni + uView Pro?

在 uniapp 构建的多端工程中,速度与一致性至关重要。

create-uni 提供一键生成、模板丰富的项目引导能力,而 uView Pro 则是基于 Vue3 + TypeScript 全面重构的高质量 uni-app 组件库。两者结合,能带来:

  • 快速上手:一行命令生成标准化项目结构;
  • 现代开发体验:Vite + Vue3 + TS,热更新快、类型友好;
  • 丰富组件:70+ 高质量组件覆盖主流业务场景;
  • 高度可扩展:uni-helper 插件体系支持文件路由、按需组件、布局系统等;
  • 企业友好:模板、样式、规范一致,便于团队协作与维护。

6.png

0.png

二、准备工作(环境与工具)

在开始之前,建议准备以下环境:

  • Node.js(建议 LTS 版本,如 18.x 或 20.x)
  • pnpm / npm / yarn(推荐 pnpm,速度更快且适合 monorepo)
  • VS Code + Volar(强烈推荐,Vue3 + TypeScript 最佳搭配,禁用 Vetur)
  • HBuilderX(如果需要使用 HBuilderX 工具链或插件市场,非必要不使用)

确保全局工具可用:

# 建议使用 pnpm
npm install -g pnpm
# 若需要全局安装脚手架(可选)
npm install -g @dcloudio/uni-app

三、使用 create-uni 快速创建项目(一步到位)

create-uni 是一套现代化脚手架,支持选择模板、快速集成 uView Pro 组件库等,下面给出用 pnpm create 的推荐流程:

# 使用 create-uni(交互式选择项目模板)
pnpm create uni@latest
cd my-uni-project
pnpm install

# 启动开发(以 H5 为例)
pnpm run dev:h5

在交互式选择时,选择需要的插件和库、选择需要的组件库 uView Pro ,可以让项目开箱即用:根据您的选择可以帮助您自动集成 uView Pro、UnoCSS、uni-helper 等插件,省去大量配置时间。

示例:

  • 选择需要的 vite 插件时勾选必要的插件:

    • vite-plugin-uni-pages(提供基于文件系统的路由)
    • vite-plugin-uni-components(按需自动引入组件)
    • vite-plugin-uni-layouts(提供类 nuxt 的 layouts 系统)
    • vite-plugin-uni-manifest(自动生成 manifest.json 文件)
  • 选择需要的库时勾选必要的库:

    • Pinia
    • Unocss
  • 选择 UI 组件库时勾选 uView Pro

通过以上选择完成后,脚手架会自动创建包含以下内容的项目:

  • Vite + uni-app 项目骨架
  • uview-pro 依赖与全局样式引入(index.scss / theme.scss)
  • 推荐的 tsconfig.jsonvite.config.ts 配置
  • UnoCSS 与 uni-helper 插件预配置

1.png

四、手动在已存在项目中安装 uView Pro(npm 或 uni_modules)

如果你已用其它方式创建项目,并不是使用 create-uni,下面是两种常见安装方式,分别适用于 CLI 项目(npm)与 HBuilderX 项目(uni_modules)。

1. CLI(npm / pnpm)方式(推荐团队/CLI 项目)

pnpm add uview-pro
# 或者 npm install uview-pro --save

在 Vue3 项目中,全局引入并注册:

// main.ts
import { createSSRApp } from "vue";
import uViewPro from "uview-pro";

export function createApp() {
  const app = createSSRApp(App);
  app.use(uViewPro);
  return {
    app,
  };
}

uni.scss 中引入主题:

@import "uview-pro/theme.scss";

在  App.vue  首行引入基础样式:

<style lang="scss">
  @import "uview-pro/index.scss";
</style>

pages.json / vite 的 easycom 配置中添加:

"easycom": {
  "autoscan": true,
  "custom": {
    "^u-(.*)": "uview-pro/components/u-$1/u-$1.vue"
  }
}

也可以使用@uni-helper/vite-plugin-uni-components(基于文件的按需组件引入)插件来替换 easycom 的方式,详细使用方式见下述介绍。

注:CLI npm 方式更易管理版本、配合 TypeScript 与 Volar 获得更好类型提示体验。

2. HBuilderX(uni_modules)方式(推荐 HBuilderX 项目)

uview-pro 目录放入项目 uni_modules 下(或通过插件市场安装);

DCloud 插件市场:ext.dcloud.net.cn/plugin?id=2…

main.ts全局引入并注册

// main.ts
import { createSSRApp } from 'vue'
import uViewPro from "@/uni_modules/uview-pro";

export function createApp() {
  const app = createSSRApp(App)
  app.use(uViewPro)
  return {
    app
  }
}

pages.json 中配置 easycom:

"easycom": {
  "autoscan": true,
  "custom": {
    "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"
  }
}

uni.scss 中引入主题:

@import "@/uni_modules/uview-pro/theme.scss";

在  App.vue  首行引入基础样式:

<style lang="scss">
  @import "@/uni_modules/uview-pro/index.scss";
</style>

HBuilderX 下,uni_modules 更符合编辑器和打包器的约定,部分原生插件或小程序构建会更兼容。

因此:建议 CLI 项目使用 npm/pnpm 方式,HBuilderX 项目使用 uni_modules 方式

五、结合 uni-helper 插件提升开发效率

uni-helper 系列插件在 vite + uni-app 生态下提供了大量现代化的便利能力。下面按插件逐一介绍它们的作用、安装、配置示例、与 uView Pro 的配合要点以及常见注意事项。

2.png

更多用法及插件请访问 uni-helper 官网文档:uni-helper.js.org/

1. @uni-helper/vite-plugin-uni-pages(文件系统路由)

作用:

  • 自动扫描 src/pagespages 目录,基于文件系统生成路由配置,替代手动维护 pages.json 的繁琐流程;
  • 支持页面元数据、分组、全局样式定义和路由扩展;
  • 提供 virtual:uni-pages 等虚拟模块用于在代码中读取页面信息,便于构建菜单、统计或自动化文档。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-pages

基本配置(vite.config.ts):

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UniPages from "@uni-helper/vite-plugin-uni-pages";

export default defineConfig({
  plugins: [UniPages(), Uni()],
});

pages 配置示例(pages.config.ts):

import { defineUniPages } from "@uni-helper/vite-plugin-uni-pages";

export default defineUniPages({
  pages: [],
  globalStyle: {
    navigationBarTextStyle: "black",
    navigationBarTitleText: "MyApp",
  },
  subPackages: [],
});

在代码中获取页面元数据:

/// <reference types="@uni-helper/vite-plugin-uni-pages/client" />
import { pages } from "virtual:uni-pages";
console.log(pages);

与 uView Pro 的配合要点:

  • 结合 uView Pro Starter,路由自动化能让示例页面、文档 demo 与项目页面保持一致;
  • 当需要在页面自动注入组件演示或 demo 链接时,pages 元数据非常方便。

注意事项:

  • 如果同时存在手动维护的 pages.json,请确认插件优先级与覆盖规则;
  • 某些小程序平台对动态生成的路由有特殊限制,发布前务必在目标平台做真机测试。

2. @uni-helper/vite-plugin-uni-components(基于文件的按需组件引入)

作用:

  • 基于文件系统实现组件按需自动引入,类似于 Vue 的 unplugin-vue-components,但针对 uni-app 场景优化;
  • 可以替代 easycom 的全局扫描,减少启动扫描成本并提升按需加载精度;
  • 支持自定义规则、扩展第三方组件库的映射。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-components

配置示例,已经支持 uView Pro Resolver:

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UniComponents from "@uni-helper/vite-plugin-uni-components";
import { uViewProResolver } from "@uni-helper/vite-plugin-uni-components/resolvers";

export default defineConfig({
  plugins: [
    UniComponents({
      dts: true,
      resolvers: [uViewProResolver()],
    }),
    Uni(),
  ],
});

与 uView Pro 的配合要点:

  • 使用此插件可避免在 pages.json 中重复写 easycom 规则;
  • 当配合 uview-pro 时,需要引入 uViewProResolver 使用;
  • 有助于实现按需打包,减小 H5 与小程序包体积。

注意事项:

  • 部分平台(例如 HBuilderX 的旧版本)可能仍需要 pages.json 的支持,务必在迁移前做兼容性验证;
  • 对于同名组件(不同来源)要明确命名或使用手动 import 以避免歧义。

3. @uni-helper/vite-plugin-uni-layouts(布局系统)

作用:

  • 在 uni-app 中实现类似 Nuxt 的布局机制(layouts),支持多个 layout 组件、slot、以及按页面应用布局;
  • 自动扫描 src/layouts 并将页面包裹在指定布局下,简化头部/尾部/侧边栏等公共区域维护。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-layouts

配置示例:

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UniLayouts from "@uni-helper/vite-plugin-uni-layouts";

export default defineConfig({
  plugins: [UniLayouts(), Uni()],
});

使用示例:

  • src/layouts/default.vue 中定义布局:
<template>
  <div class="layout">
    <slot name="header">默认头部</slot>
    <slot>主内容</slot>
    <slot name="footer">默认底部</slot>
  </div>
</template>
  • 在页面中指定布局(definePage):
<script setup>
definePage({ layout: "default" });
</script>

与 uView Pro 的配合要点:

  • 布局中可直接使用 uView Pro 的导航栏、Tabbar、Footer 等组件,保证风格统一;
  • 结合 uView Pro Starter,布局示例通常已经内置,直接复用即可。

注意事项:

  • 在微信小程序中如果页面使用 web-view,布局插件的包裹机制可能不生效;
  • 动态切换布局时注意保持页面状态。

4. @uni-helper/vite-plugin-uni-manifest(用 TypeScript 管理 manifest)

作用:

  • 允许使用 TypeScript 编写 manifest.json(如 manifest.config.ts),享受类型提示与可组合的配置方式;
  • 在构建时自动生成标准 manifest.json,并支持按平台差异化配置。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-manifest

配置示例:

import Uni from "@uni-helper/plugin-uni";
import UniManifest from "@uni-helper/vite-plugin-uni-manifest";

export default defineConfig({
  plugins: [UniManifest(), Uni()],
});

示例 manifest.config.ts

import { defineManifestConfig } from "@uni-helper/vite-plugin-uni-manifest";

export default defineManifestConfig({
  appid: "your-appid",
  name: "MyApp",
  versionName: "1.0.0",
  h5: {
    devServer: {
      port: 8080,
    },
  },
});

与 uView Pro 的配合要点:

  • 将 theme 或构建相关的配置以类型化方式管理,便于在不同环境(dev/staging/prod)间切换;
  • 在企业项目中能更方便地实现 CI 自动化生成不同渠道包的 manifest 配置。

注意事项:

  • 生成的 manifest.json 应在真机或云打包平台上验证,避免配置项平台不兼容。

5. @uni-helper/vite-plugin-uni-platform(按平台文件替换)

作用:

  • 支持基于文件名的按平台编译,例如 index.h5.vueindex.mp-weixin.vueindex.app.vue 等,构建时自动替换为对应平台文件;
  • 便于按平台做差异化实现,同时保持统一的项目结构与代码管理。

安装:

pnpm add -D @uni-helper/vite-plugin-uni-platform

配置示例:

import Uni from "@uni-helper/plugin-uni";
import UniPlatform from "@uni-helper/vite-plugin-uni-platform";

export default defineConfig({
  plugins: [UniPlatform(), Uni()],
});

使用说明:

  • 在项目中创建文件如 pages/index.h5.vue 针对 H5 的实现,pages/index.mp-weixin.vue 针对微信小程序的实现;
  • 在编译目标为 H5 时,会优先使用 index.h5.vue,否则退回 index.vue

与 uView Pro 的配合要点:

  • 当使用 uView Pro 的某些平台相关适配(例如原生 SDK 或特定 API)时,可以在平台特定文件中做针对性封装;
  • 结合 uni-pages,能更方便地管理平台差异化页面列表。

注意事项:

  • 使用大量平台特异化文件会增加维护成本,建议仅在必要场景使用。

6. @uni-helper/unocss-preset-uni(UnoCSS 预设)

作用:

  • 为 uni-app 定制的 UnoCSS 预设,开箱即用的原子类工具集,支持属性化写法与按平台样式差异;
  • 极大减少重复样式、提高开发速度,同时配合 Uno 的即时编译,开发体验流畅。

安装:

pnpm add -D @uni-helper/unocss-preset-uni unocss unocss-applet

vite 配置示例:

import { defineConfig } from "vite";
import Uni from "@uni-helper/plugin-uni";
import UnoCSS from "unocss/vite";

export default defineConfig({
  plugins: [Uni(), UnoCSS()],
});

uno.config.ts 配置

import { presetUni } from "@uni-helper/unocss-preset-uni";

import {
  defineConfig,
  presetIcons,
  transformerDirectives,
  transformerVariantGroup,
} from "unocss";

export default defineConfig({
  presets: [
    presetUni({
      attributify: {
        // UnoCSS的解析规则可与uView Pro组件库内置样式冲突
        ignoreAttributes: ["size"],
      },
    }),
  ],
  transformers: [transformerDirectives(), transformerVariantGroup()],
});

与 uView Pro 的配合要点:

  • UnoCSS 非侵入式,可与 uView Pro 的 SCSS 主题变量共存;
  • 在快速原型或设计系统中,Uno 的原子类能极大提升迭代速度;
  • 推荐将设计变量(颜色、间距)同步到 uView Pro 的 theme.scss,并在 Uno 配置中复用。
  • 注意 UnoCSS 的解析规则可能会与 uView Pro 组件库内置样式冲突

注意事项:

  • UnoCSS 从 v0.59 起只提供 ESM 支持,某些老旧构建环境需降级或额外配置;
  • 在使用 apis 或小程序特性时,注意属性名与平台限制。

7. 插件组合示例(完整 vite.config.ts)

下面给出一个常见的 vite.config.ts 组合示例,展示如何把上面插件整合到同一个工程中:

import { fileURLToPath, URL } from "node:url";

import Uni from "@uni-helper/plugin-uni";
import Components from "@uni-helper/vite-plugin-uni-components";
import { uViewProResolver } from "@uni-helper/vite-plugin-uni-components/resolvers";
import UniLayouts from "@uni-helper/vite-plugin-uni-layouts";
import UniManifest from "@uni-helper/vite-plugin-uni-manifest";
import UniMiddleware from "@uni-helper/vite-plugin-uni-middleware";
import UniPages from "@uni-helper/vite-plugin-uni-pages";
import UniPlatform from "@uni-helper/vite-plugin-uni-platform";
import UniPlatformModifier from "@uni-helper/vite-plugin-uni-platform-modifier";
import UniRoot from "@uni-ku/root";
import UnoCSS from "unocss/vite";
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
  plugins: [
    Components({
      dts: true,
      resolvers: [uViewProResolver()],
    }),
    UniPages(),
    UniLayouts(),
    UniManifest(),
    UniPlatform(),
    UniPlatformModifier(),
    UniMiddleware(),
    UniRoot(),
    Uni(),
    UnoCSS(),
  ],
});

8. 常见故障排查(针对插件集成)

  • uni-pages 未识别页面:确认目录结构、文件后缀以及 pages.config.* 是否存在语法错误;
  • uni-components 未按需引入:检查插件 dirs 配置与组件命名是否匹配,或手动添加 resolver;
  • layouts 无效:确认页面是否使用 definePage({ layout: 'xxx' }) 或 pages.json 的 layout 配置被覆盖;
  • manifest 生成错误:在本地构建时查看生成的 manifest.json,并在真机或云打包平台验证;
  • UnoCSS 样式不生效:确认 UnoCSS 是否在 plugins 列表中且 preset 已正确加载;
  • uView Pro 组件样式错乱:确认 UnoCss 解析规则是否与组件库存在冲突问题;

六、uView Pro Starter:开箱即用的项目模板

uView Pro Starter 是官方维护的快速启动模板,目前集成了 create-uni、uView Pro、UnoCSS 与 uni-helper 常用插件,适合作为企业或个人项目的起点。核心优势包括:

  • 规范的项目结构与开发脚本;
  • 预配置的 linter、格式化、TypeScript 与 Volar 支持;
  • UnoCSS 和主题变量已集成,支持快速定制风格;
  • 常用页面、布局、示例组件齐全,便于二次开发。

快速使用:

# 直接 clone
git clone https://github.com/anyup/uView-Pro-Starter.git
cd uView-Pro-Starter
pnpm install
pnpm run dev:h5

后面可以通过 create-uni 直接选择 uView Pro Starter 模板,目前还没建设完成。

Starter 的目的是把工程化、规范、常见实践都“开箱即用”,让团队把精力集中在业务实现上,而不是基础设施搭建。

3.png

5.png

4.png

七、uView Pro 与 uni-helper 的协同最佳实践(总结)

  • 使用 uView Pro Starter 作为项目模板,默认预集成了大部分插件配置,能让团队开箱即用;
  • 对于页面与组件的自动化引入,优先考虑 uni-pages + uni-components,降低重复维护成本;
  • uni-components 中为 u- 前缀做显式 resolver,避免与其他库冲突;
  • 将 uView Pro 的主题变量与 UnoCSS 的设计 tokens 做映射,保证样式统一且可维护;
  • 在 CI 中加入 pnpm install --frozen-lockfile、lint、typecheck 步骤,保证团队一致性;
  • 做好平台差异化管理(合理使用 uni-platform)但尽量减少全平台分支,以降低维护成本。

八、注意事项

1. 样式、sass 与版本兼容建议

在实际项目中,sass 与 sass-loader 的版本兼容性常会引发样式构建问题。建议在团队内统一并锁定版本,减少“本地能跑、CI 失败”的尴尬。

推荐版本(uView Pro 社区实践验证):

"sass": "1.63.2",
"sass-loader": "10.4.1"

同时,注意 uView Pro 的内部样式及主题文件采用 @import 形式引入。所以一定要注意 sass 的版本,

如使用 @use / @forward 语法引入 uView Pro 的样式文件,可能会导致样式丢失,报错,所以请使用 @import 引入。

2. TypeScript、Volar 与类型提示体验

uView Pro 自带 TypeScript 类型声明文件,结合 Volar 能获得良好的组件属性、事件、插槽的代码补全与类型校验。以下为推荐配置:

  1. 确保 VS Code 安装 Volar,并禁用 Vetur
  2. tsconfig.json 中添加:
{
  "compilerOptions": {
    "types": ["uview-pro/types"],
    "skipLibCheck": true
  }
}
  1. 在团队中统一 tsconfig 与 VS Code 推荐扩展配置(.vscode/extensions.json),减少“我的能提示你的不能提示”的现象。

3. 按需加载、tree-shaking 与打包优化

为减小包体积,建议:

  • 优先按需导入工具函数与业务组件(避免全局引入全部组件),
  • 使用 uni-helper 的 uni-components 或配合 Vite 的按需加载插件实现自动 tree-shaking,
  • 对大型列表使用虚拟滚动、分页或懒加载,
  • 在生产构建时开启压缩、静态资源缓存以及 CDN/边缘分发。

示例:按需引入工具函数

import { deepClone } from "uview-pro";
const copy = deepClone(obj);

4. 与其他组件库共存的注意事项

项目中若存在 uview-plusuView 1.xuView 2.x 或其他同类库,可能会出现 easycom 冲突、样式覆盖或工具命名冲突。解决建议:

  • 在迁移期避免自动扫描多个组件库的同名规则;
  • 调整 easycom.custom 规则,只指向 uview-pro 或具体库路径;
  • 团队层面统一组件库选型,减少冲突成本。

5. 常见问题与排查清单

  • 组件没有样式?→ 检查 theme.scssindex.scss 是否正确引入;
  • easycom 无效?→ 检查 pages.jsoncustom 配置与路径;
  • Volar 无补全?→ 禁用 Vetur、重启 VS Code、确认 tsconfig.json 设置;
  • Sass 语法报错?→ 检查 sasssass-loader 版本并统一锁定;
  • 依赖冲突?→ 清理 node_modules / pnpm install --frozen-lockfile 并统一依赖来源。

更多常见问题请参考社区网站,实时更新:uviewpro.cn/zh/guide/fa…

九、uView Pro(为开源而生)

uView Pro 是一款免费、开源、面向个人和企业的组件库。希望通过 uView Pro Startercreate-uni 的结合,降低团队上手成本,提高项目启动速度。

同时欢迎企业与开发者在 GitHub / Gitee 提交 PR、Issue,参与组件优化、示例补全与文档改进。

项目地址

十、结语:把时间交给业务,把基础交给 uView Pro

通过 create-uni + uView Pro + uni-helper 插件体系,你可以在极短的时间内搭建一个现代化、可维护、类型安全的 uni-app 项目。无论是单人项目、快速原型,还是企业级多团队协作,这套组合都能显著降低启动成本、提高开发效率。

所以,强烈建议你:

  • 使用 uView Pro Starter,将其作为项目起点;或者使用 create-uni 创建新项目时选择包含 uView Pro 的模板;
  • 合理使用 uni-helper 插件系统,减少重复工作;
  • 在团队内推广统一模板与依赖锁定策略;

欢迎访问与关注:

一个前端工程师的年度作品:从零开发媲美商业级应用的后台管理系统!

2025年10月14日 16:04

@2x封面.png

过去一年,我花了无数个夜晚,在一次次打磨与推翻中,完成了自己最满意的作品 —— Art Design Pro。

这不是一个普通的后台模板。
它是一场关于 「设计美学」与「工程化开发」 的融合实验——
希望让后台系统不再冰冷枯燥,而是像一件作品:优雅、流畅、有温度。

为什么要做这个项目?

在日常开发中,我几乎体验过所有主流后台模板。
它们的功能确实完整,但更多时候给人的感觉是——「工具」而非「产品」。

我希望做一个后台系统,
让人打开的第一眼就觉得舒服,
用起来像是在和它对话。

许多后台系统普遍存在这些问题 👇

视觉疲劳:灰白配色、生硬布局,难以长时间使用;

体验割裂:逻辑不统一、入口分散,操作效率低;

复用困难:组件风格不一致,二次开发成本高。

于是我决定从零开始,花一年的时间去打造一个真正属于自己的系统——
👉 一款 “既好看、又好用,还能直接用于商业项目” 的后台管理模板。

项目简介:Art Design Pro

Art Design Pro 是一款基于 Vue 3 + TypeScript + Vite + Element Plus 打造的现代化后台管理系统模板。

它的核心理念是:

让后台系统兼具设计美学与开发效率。

这个项目有什么特别的呢?

界面设计:现代化 UI 设计,流畅交互,以用户体验与视觉设计为核心

极速上手:简洁架构 + 完整文档,后端开发者也能轻松使用

丰富组件:内置数据展示、表单等多种高质量组件,满足不同业务场景的需求

丝滑交互:按钮点击、主题切换、页面过渡、图表动画,体验媲美商业产品

高效开发:内置 useTable、ArtForm 等实用 API,显著提升开发效率

精简脚本:内置一键清理脚本,可快速清理演示数据,立即得到可开发的基础项目

技术栈

开发框架:Vue3、TypeScript、Vite、Element-Plus

代码规范:Eslint、Prettier、Stylelint、Husky、Lint-staged、cz-git

预览

主页仪表盘

电子商务仪表盘

卡片

横幅

图表

系统图标库

富文本编辑器

礼花效果

全局搜索

系统设置

表格

暗黑模式


快速访问

GitHub: github.com/Daymychen/a…

演示地址: www.artd.pro

官方文档: www.artd.pro/docs

高效开发

在后台管理系统开发中,表格页面占据了 80% 的工作量。每次都要写分页、搜索、刷新、列配置...这些重复的代码让人头疼。今天分享一套我们团队正在使用的表格开发方案,让你的开发效率提升 10 倍!

痛点分析

在开发后台管理系统时,你是否遇到过这些问题:

  • 每个表格页面都要写一遍分页逻辑
  • 搜索、重置、刷新等功能重复实现
  • 表格列配置、显示隐藏、拖拽排序需要手动处理
  • 数据请求、loading 状态、错误处理代码冗余
  • 缓存策略难以统一管理
  • 移动端适配需要额外处理

如果你的答案是"是",那这篇文章就是为你准备的。

解决方案概览

我们的方案包含以下核心部分:

  • useTable - 强大的表格数据管理 Hook
  • ArtTable - 增强的表格组件
  • ArtTableHeader - 表格工具栏组件
  • ArtSearchBar - 智能搜索栏组件
  • ArtForm - 通用表单组件

一、useTable:表格数据管理的核心

1.1 基础用法

先看一个最简单的例子,只需要几行代码就能实现一个完整的表格:

const {
  data,
  columns,
  columnChecks,
  loading,
  pagination,
  refreshData,
  handleSizeChange,
  handleCurrentChange
} = useTable({
  core: {
    apiFn: fetchGetUserList,
    apiParams: {
      current: 1,
      size: 20
    },
    columnsFactory: () => [
      { prop: 'id', label: 'ID' },
      { prop: 'userName', label: '用户名' },
      { prop: 'userPhone', label: '手机号' }
    ]
  }
})

就这么简单!你已经拥有了:

  • ✅ 自动的数据请求
  • ✅ 分页功能
  • ✅ Loading 状态
  • ✅ 列配置管理

1.2 核心特性

🚀 智能缓存机制

useTable({
  core: {
    /* ... */
  },
  performance: {
    enableCache: true, // 启用缓存
    cacheTime: 5 * 60 * 1000, // 缓存 5 分钟
    debounceTime: 300, // 防抖 300ms
    maxCacheSize: 50 // 最多缓存 50 条
  }
})

缓存带来的好处:

  • 相同参数的请求直接从缓存读取,秒开
  • 减少服务器压力
  • 提升用户体验

🎯 多种刷新策略

不同的业务场景需要不同的刷新策略:

// 新增数据后:回到第一页,清空分页缓存
await refreshCreate()

// 编辑数据后:保持当前页,只清空当前搜索缓存
await refreshUpdate()

// 删除数据后:智能处理页码,避免空页面
await refreshRemove()

// 手动刷新:清空所有缓存
await refreshData()

// 定时刷新:轻量刷新,保持分页状态
await refreshSoft()

这些方法让你的代码更语义化,不用再纠结什么时候该清缓存。

🔄 数据转换器

有时候接口返回的数据需要处理一下才能用:

useTable({
  core: {
    /* ... */
  },
  transform: {
    dataTransformer: (records) => {
      return records.map((item, index) => ({
        ...item,
        // 替换头像
        avatar: localAvatars[index % localAvatars.length].avatar,
        // 格式化日期
        createTime: dayjs(item.createTime).format('YYYY-MM-DD')
      }))
    }
  }
})

📊 生命周期钩子

useTable({
  core: {
    /* ... */
  },
  hooks: {
    onSuccess: (data, response) => {
      console.log('数据加载成功', data)
    },
    onError: (error) => {
      ElMessage.error('加载失败:' + error.message)
    },
    onCacheHit: (data) => {
      console.log('从缓存读取', data)
    }
  }
})

1.3 搜索功能

搜索是表格的核心功能,useTable 提供了完善的搜索支持:

// 定义搜索参数
const searchParams = reactive({
  userName: '',
  userPhone: '',
  status: '1'
})

const { getData, resetSearchParams } = useTable({
  core: {
    apiFn: fetchGetUserList,
    apiParams: searchParams
  }
})

// 搜索
const handleSearch = (params) => {
  Object.assign(searchParams, params)
  getData() // 自动回到第一页
}

// 重置
const handleReset = () => {
  resetSearchParams() // 清空搜索条件并重新加载
}

二、ArtTable:增强的表格组件

2.1 核心特性

ArtTable 基于 Element Plus 的 ElTable 封装,完全兼容原有 API,同时提供了更多增强功能:

<ArtTable
  :loading="loading"
  :data="data"
  :columns="columns"
  :pagination="pagination"
  @selection-change="handleSelectionChange"
  @pagination:size-change="handleSizeChange"
  @pagination:current-change="handleCurrentChange"
/>

✨ 自动高度计算

不用再手动计算表格高度了!ArtTable 会自动计算剩余空间:

<div class="art-full-height">
  <UserSearch />
  <ElCard class="art-table-card">
    <ArtTableHeader />
    <ArtTable /> <!-- 自动占满剩余高度 -->
  </ElCard>

</div>

🎨 列配置灵活

支持多种列类型和自定义渲染:

columnsFactory: () => [
  { type: 'selection' }, // 勾选列
  { type: 'index', width: 60, label: '序号' }, // 序号列
  { type: 'globalIndex' }, // 全局序号(跨页)

  // 自定义渲染
  {
    prop: 'avatar',
    label: '用户',
    formatter: (row) => {
      return h('div', { class: 'user-info' }, [
        h(ElImage, { src: row.avatar }),
        h('span', row.userName)
      ])
    }
  },

  // 使用插槽
  {
    prop: 'status',
    label: '状态',
    useSlot: true // 在模板中使用 #status 插槽
  },

  // 操作列
  {
    prop: 'operation',
    label: '操作',
    fixed: 'right',
    formatter: (row) =>
      h('div', [
        h(ArtButtonTable, {
          type: 'edit',
          onClick: () => handleEdit(row)
        }),
        h(ArtButtonTable, {
          type: 'delete',
          onClick: () => handleDelete(row)
        })
      ])
  }
]

📱 响应式分页

自动适配移动端、平板、桌面端:

// 移动端:prev, pager, next, sizes, jumper, total
// 平板:prev, pager, next, jumper, total
// 桌面端:total, prev, pager, next, sizes, jumper

三、ArtTableHeader:强大的工具栏

3.1 开箱即用的功能

<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
  <template #left>
    <ElButton @click="handleAdd">新增用户</ElButton>

  </template>

</ArtTableHeader>

一行代码,你就拥有了:

  • 🔍 搜索栏切换
  • 🔄 刷新按钮(带加载动画)
  • 📏 表格尺寸切换(大、中、小)
  • 🖥️ 全屏模式
  • 📋 列显示/隐藏配置
  • 🎨 斑马纹、边框、表头背景切换

3.2 列配置功能

用户可以:

  • ✅ 勾选显示/隐藏列
  • ✅ 拖拽调整列顺序
  • ✅ 固定列不可拖动

这些配置会自动同步到表格显示。

3.3 自定义布局

<ArtTableHeader layout="refresh,size,fullscreen,columns" :show-zebra="false" :show-border="false" />

通过 layout 属性控制显示哪些功能按钮。

四、ArtSearchBar:智能搜索栏

4.1 配置化搜索表单

不用再手写一堆 ElFormItem 了,用配置就能搞定:

<template>
  <ArtSearchBar
    ref="searchBarRef"
    v-model="formData"
    :items="formItems"
    :rules="rules"
    @reset="handleReset"
    @search="handleSearch"
  />
</template>

<script setup lang="ts">
  const formData = ref({
    userName: undefined,
    userPhone: undefined,
    status: '1'
  })

  const formItems = computed(() => [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        placeholder: '请选择状态',
        options: [
          { label: '在线', value: '1' },
          { label: '离线', value: '2' }
        ]
      }
    },
    {
      label: '性别',
      key: 'userGender',
      type: 'radiogroup',
      props: {
        options: [
          { label: '男', value: '1' },
          { label: '女', value: '2' }
        ]
      }
    }
  ])

  const handleSearch = async () => {
    await searchBarRef.value.validate()
    emit('search', formData.value)
  }

  const handleReset = () => {
    emit('reset')
  }
</script>

4.2 核心特性

🎯 支持多种表单组件

开箱即用的组件类型:

  • input - 输入框
  • select - 下拉选择
  • date / datetime / daterange - 日期选择
  • radiogroup / checkboxgroup - 单选/多选
  • cascader - 级联选择
  • treeselect - 树选择
  • 自定义组件 - 支持任意 Vue 组件

📦 自动展开/收起

当搜索项过多时,自动显示展开/收起按钮:

<ArtSearchBar :items="formItems" :show-expand="true" :default-expanded="false" :span="6" />
  • 默认只显示一行
  • 超出部分自动隐藏
  • 点击"展开"查看全部搜索项

🔄 动态选项加载

支持异步加载选项数据:

const statusOptions = ref([])

onMounted(async () => {
  // 模拟接口请求
  statusOptions.value = await fetchStatusOptions()
})

const formItems = computed(() => [
  {
    label: '状态',
    key: 'status',
    type: 'select',
    props: {
      options: statusOptions.value // 动态选项
    }
  }
])

🎨 响应式布局

自动适配不同屏幕尺寸:

// 通过 span 控制每行显示的表单项数量
// span=6: 一行显示 4 个(24/6=4)
// span=8: 一行显示 3 个(24/8=3)
// span=12: 一行显示 2 个(24/12=2)

移动端自动调整为单列布局。

4.3 表单校验

支持完整的表单校验:

const rules = {
  userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  userPhone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ]
}

const handleSearch = async () => {
  // 校验通过才执行搜索
  await searchBarRef.value.validate()
  emit('search', formData.value)
}

五、ArtForm:通用表单组件

5.1 配置化表单

ArtForm 和 ArtSearchBar 使用相同的配置方式,但更适合弹窗、详情页等场景:

<template>
  <ArtForm
    ref="formRef"
    v-model="formData"
    :items="formItems"
    :rules="formRules"
    :label-width="100"
    :span="12"
    @reset="handleReset"
    @submit="handleSubmit"
  />
</template>

<script setup lang="ts">
  const formData = ref({
    userName: '',
    userPhone: '',
    userEmail: '',
    userGender: '1',
    status: true
  })

  const formItems = [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名'
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { maxlength: 11 }
    },
    {
      label: '邮箱',
      key: 'userEmail',
      type: 'input',
      placeholder: '请输入邮箱'
    },
    {
      label: '性别',
      key: 'userGender',
      type: 'radiogroup',
      props: {
        options: [
          { label: '男', value: '1' },
          { label: '女', value: '2' }
        ]
      }
    },
    {
      label: '是否启用',
      key: 'status',
      type: 'switch'
    },
    {
      label: '备注',
      key: 'remark',
      type: 'input',
      span: 24, // 占满整行
      props: {
        type: 'textarea',
        rows: 4
      }
    }
  ]

  const formRules = {
    userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    userEmail: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
  }

  const handleSubmit = async () => {
    await formRef.value.validate()
    // 提交表单
    console.log('表单数据:', formData.value)
  }
</script>

5.2 高级特性

🎨 自定义组件渲染

支持使用 h 函数渲染任意组件:

import ArtIconSelector from '@/components/core/base/art-icon-selector/index.vue'

const formItems = [
  {
    label: '图标选择',
    key: 'icon',
    type: () =>
      h(ArtIconSelector, {
        iconType: IconTypeEnum.UNICODE,
        width: '100%'
      })
  },
  {
    label: '文件上传',
    key: 'files',
    type: () =>
      h(
        ElUpload,
        {
          action: '#',
          multiple: true,
          limit: 5,
          onChange: (file, fileList) => {
            formData.value.files = fileList
          }
        },
        {
          default: () => h(ElButton, { type: 'primary' }, () => '点击上传')
        }
      )
  }
]

🔌 插槽支持

支持为表单项添加插槽:

<ArtForm v-model="formData" :items="formItems">
  <template #customField>
    <div class="custom-content">
      <!-- 自定义内容 -->
    </div>

  </template>

</ArtForm>

或者在配置中使用插槽:

const formItems = [
  {
    label: '网站',
    key: 'website',
    type: 'input',
    slots: {
      prepend: () => h('span', 'https://'),
      append: () => h('span', '.com')
    }
  }
]

🎯 条件显示

根据条件动态显示/隐藏表单项:

const formItems = computed(() => [
  {
    label: '用户类型',
    key: 'userType',
    type: 'select',
    props: {
      options: [
        { label: '个人', value: 'personal' },
        { label: '企业', value: 'enterprise' }
      ]
    }
  },
  {
    label: '企业名称',
    key: 'companyName',
    type: 'input',
    // 只有选择企业类型时才显示
    hidden: formData.value.userType !== 'enterprise'
  }
])

📏 灵活布局

通过 span 控制表单项宽度:

const formItems = [
  {
    label: '用户名',
    key: 'userName',
    type: 'input',
    span: 12 // 占半行
  },
  {
    label: '手机号',
    key: 'userPhone',
    type: 'input',
    span: 12 // 占半行
  },
  {
    label: '地址',
    key: 'address',
    type: 'input',
    span: 24 // 占整行
  }
]

5.3 与 ArtSearchBar 的区别

特性 ArtSearchBar ArtForm
使用场景 表格搜索 表单提交、弹窗
展开/收起 ✅ 支持 ❌ 不支持
按钮文案 搜索/重置 提交/重置
样式 卡片样式 无背景
默认布局 横向排列 横向排列

六、完整示例

让我们看一个完整的用户管理页面:

<template>
  <div class="user-page art-full-height">
    <!-- 搜索栏 -->
    <ArtSearchBar
      v-model="searchForm"
      :items="searchItems"
      @search="handleSearch"
      @reset="resetSearchParams"
    />

    <ElCard class="art-table-card" shadow="never">
      <!-- 工具栏 -->
      <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
        <template #left>
          <ElButton @click="handleAdd">新增用户</ElButton>

        </template>

      </ArtTableHeader>

      <!-- 表格 -->
      <ArtTable
        :loading="loading"
        :data="data"
        :columns="columns"
        :pagination="pagination"
        @selection-change="handleSelectionChange"
        @pagination:size-change="handleSizeChange"
        @pagination:current-change="handleCurrentChange"
      />
    </ElCard>

    <!-- 弹窗 -->
    <UserDialog
      v-model:visible="dialogVisible"
      :type="dialogType"
      :user-data="currentUserData"
      @submit="handleDialogSubmit"
    />
  </div>

</template>

<script setup lang="ts">
  import { useTable } from '@/composables/useTable'
  import { fetchGetUserList } from '@/api/system-manage'
  import UserDialog from './modules/user-dialog.vue'

  // 搜索表单
  const searchForm = ref({
    userName: undefined,
    userPhone: undefined,
    status: '1'
  })

  // 搜索项配置
  const searchItems = [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
    },
    {
      label: '状态',
      key: 'status',
      type: 'select',
      props: {
        placeholder: '请选择状态',
        options: [
          { label: '在线', value: '1' },
          { label: '离线', value: '2' },
          { label: '异常', value: '3' }
        ]
      }
    }
  ]

  // 表格配置
  const {
    data,
    columns,
    columnChecks,
    loading,
    pagination,
    getData,
    searchParams,
    resetSearchParams,
    handleSizeChange,
    handleCurrentChange,
    refreshData
  } = useTable({
    core: {
      apiFn: fetchGetUserList,
      apiParams: {
        current: 1,
        size: 20,
        ...searchForm.value
      },
      columnsFactory: () => [
        { type: 'selection' },
        { type: 'index', width: 60, label: '序号' },
        {
          prop: 'avatar',
          label: '用户名',
          width: 280,
          formatter: (row) => {
            return h('div', { class: 'user-info' }, [
              h(ElImage, { src: row.avatar }),
              h('div', [h('p', row.userName), h('p', { class: 'email' }, row.userEmail)])
            ])
          }
        },
        { prop: 'userGender', label: '性别' },
        { prop: 'userPhone', label: '手机号' },
        {
          prop: 'status',
          label: '状态',
          formatter: (row) => {
            const config = getStatusConfig(row.status)
            return h(ElTag, { type: config.type }, () => config.text)
          }
        },
        {
          prop: 'operation',
          label: '操作',
          width: 120,
          fixed: 'right',
          formatter: (row) =>
            h('div', [
              h(ArtButtonTable, {
                type: 'edit',
                onClick: () => handleEdit(row)
              }),
              h(ArtButtonTable, {
                type: 'delete',
                onClick: () => handleDelete(row)
              })
            ])
        }
      ]
    }
  })

  // 搜索处理
  const handleSearch = (params) => {
    Object.assign(searchParams, params)
    getData()
  }

  // 新增
  const handleAdd = () => {
    dialogType.value = 'add'
    dialogVisible.value = true
  }

  // 编辑
  const handleEdit = (row) => {
    dialogType.value = 'edit'
    currentUserData.value = row
    dialogVisible.value = true
  }

  // 删除
  const handleDelete = (row) => {
    ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
      type: 'warning'
    }).then(() => {
      // 调用删除接口
      // await deleteUser(row.id)
      ElMessage.success('删除成功')
      refreshData()
    })
  }
</script>

七、性能优化

7.1 智能防抖

搜索时自动防抖,避免频繁请求:

const { getDataDebounced } = useTable({
  performance: {
    debounceTime: 300 // 300ms 防抖
  }
})

// 使用防抖搜索
const handleSearch = () => {
  getDataDebounced()
}

7.2 请求取消

切换页面或快速切换搜索条件时,自动取消上一个请求:

// useTable 内部实现
let abortController = new AbortController()

const fetchData = async () => {
  // 取消上一个请求
  if (abortController) {
    abortController.abort()
  }

  // 创建新的控制器
  abortController = new AbortController()

  // 发起请求
  await apiFn(params)
}

7.3 缓存统计

实时查看缓存状态:

const { cacheInfo } = useTable({
  performance: { enableCache: true }
})

console.log(cacheInfo.value)
// { total: 10, size: '45KB', hitRate: '8 avg hits' }

八、最佳实践

8.1 目录结构

views/
  system/
    user/
      index.vue           # 主页面
      modules/
        user-search.vue   # 搜索组件
        user-dialog.vue   # 弹窗组件

8.2 搜索组件封装

使用 ArtSearchBar 封装搜索组件:

<!-- user-search.vue -->
<template>
  <ArtSearchBar
    ref="searchBarRef"
    v-model="formData"
    :items="formItems"
    @reset="handleReset"
    @search="handleSearch"
  />
</template>

<script setup lang="ts">
  const formData = defineModel({ required: true })
  const emit = defineEmits(['search', 'reset'])

  const formItems = [
    {
      label: '用户名',
      key: 'userName',
      type: 'input',
      placeholder: '请输入用户名',
      clearable: true
    },
    {
      label: '手机号',
      key: 'userPhone',
      type: 'input',
      props: { placeholder: '请输入手机号', maxlength: '11' }
    }
  ]

  const handleSearch = () => {
    emit('search', formData.value)
  }

  const handleReset = () => {
    emit('reset')
  }
</script>

8.3 类型安全

充分利用 TypeScript 的类型推导:

// API 类型定义
declare namespace Api.SystemManage {
  interface UserListItem {
    id: number
    userName: string
    userPhone: string
    userEmail: string
    status: string
    avatar: string
  }

  interface UserSearchParams {
    userName?: string
    userPhone?: string
    status?: string
  }
}

// useTable 会自动推导类型
const { data } = useTable({
  core: {
    apiFn: fetchGetUserList // 返回 Promise<UserListItem[]>
    // data 的类型自动推导为 UserListItem[]
  }
})

九、对比传统方案

传统方案(约 200 行代码)

// 需要手动管理的状态
const loading = ref(false)
const data = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const searchForm = ref({})

// 需要手动实现的方法
const fetchData = async () => {
  loading.value = true
  try {
    const res = await api.getUserList({
      current: currentPage.value,
      size: pageSize.value,
      ...searchForm.value
    })
    data.value = res.records
    total.value = res.total
  } catch (error) {
    console.error(error)
  } finally {
    loading.value = false
  }
}

const handleSizeChange = (val) => {
  pageSize.value = val
  currentPage.value = 1
  fetchData()
}

const handleCurrentChange = (val) => {
  currentPage.value = val
  fetchData()
}

const handleSearch = () => {
  currentPage.value = 1
  fetchData()
}

const handleReset = () => {
  searchForm.value = {}
  currentPage.value = 1
  fetchData()
}

// ... 还有更多代码

使用 useTable(约 30 行代码)

const {
  data,
  columns,
  loading,
  pagination,
  searchParams,
  getData,
  resetSearchParams,
  handleSizeChange,
  handleCurrentChange
} = useTable({
  core: {
    apiFn: fetchGetUserList,
    apiParams: { current: 1, size: 20 },
    columnsFactory: () => [
      /* 列配置 */
    ]
  }
})

const handleSearch = (params) => {
  Object.assign(searchParams, params)
  getData()
}

代码量减少 85%,功能更强大!

十、总结

这套表格开发方案的核心优势:

  1. 开发效率提升 10 倍 - 从 200 行代码减少到 30 行
  2. 功能更强大 - 缓存、防抖、多种刷新策略、列配置等
  3. 类型安全 - 完整的 TypeScript 支持
  4. 易于维护 - 统一的代码风格和最佳实践
  5. 用户体验好 - 响应式设计、智能缓存、流畅交互

如果你也在开发后台管理系统,强烈建议尝试这套方案。它不仅能让你的代码更简洁,还能让你有更多时间专注于业务逻辑,而不是重复造轮子。

如果这篇文章对你有帮助,欢迎点赞收藏!有任何问题也欢迎在评论区讨论 💬

Vue 组件通信的两种世界观:`.sync` 与普通 `props` 到底有什么不同?

2025年10月11日 18:57
.sync 则通过约定事件 update:propName,让子组件可上抛修改意图,由父组件执行更新,从而在不破坏单向数据流的前提下实现“受控同步”。它统一了父子通信模式,简化代码,保持数据流向清晰。

2025WebAssembly详解

2025年10月11日 12:00

WebAssembly详解

引言

WebAssembly(简称Wasm)是一项革命性的Web技术,它为Web平台带来了接近原生的性能。作为继JavaScript之后的第四种Web语言(HTML、CSS、JavaScript之后),WebAssembly正在改变我们对Web应用性能和功能的认知。

什么是WebAssembly

WebAssembly是一种低级类汇编语言,具有紧凑的二进制格式,可以在现代Web浏览器中以接近原生的性能运行。它被设计为一种编译目标,允许C、C++、Rust等语言编写的代码在Web环境中运行。

WebAssembly的历史背景

WebAssembly的发展历程可以追溯到2015年,当时Mozilla、Google、Microsoft和Apple等主要浏览器厂商开始合作开发这一技术。2017年,WebAssembly正式成为W3C推荐标准,标志着它成为了Web平台的正式组成部分。

WebAssembly核心概念

字节码格式

WebAssembly的核心是其二进制格式,这种格式具有以下特点:

  • 紧凑性:相比文本格式,二进制格式更小,加载更快
  • 可读性:提供文本格式(.wat)用于调试和学习
  • 高效解析:浏览器可以快速解析和编译
  • 确定性:严格的规范确保跨平台一致性

虚拟机模型

WebAssembly运行在一个沙箱化的虚拟机中,具有以下特性:

  • 线性内存模型:使用单一的连续内存块
  • 栈式架构:基于栈的执行模型
  • 静态类型系统:所有类型在编译时确定
  • 确定性执行:相同输入总是产生相同输出

模块系统

WebAssembly程序以模块(Module)为单位组织,每个模块包含:

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
  (export "add" (func $add))
)

WebAssembly与JavaScript的互操作

导入和导出

WebAssembly模块可以导入JavaScript函数,也可以导出函数供JavaScript调用:

// JavaScript中使用WebAssembly
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('math.wasm'),
  {
    // 导入对象
    env: {
      consoleLog: (value) => console.log(value)
    }
  }
);

// 调用导出的函数
const result = wasmModule.instance.exports.add(5, 3);

内存共享

WebAssembly和JavaScript可以共享内存:

// 创建共享内存
const memory = new WebAssembly.Memory({ initial: 256, maximum: 256 });

// 传递给WebAssembly模块
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('program.wasm'),
  { env: { memory } }
);

// 在JavaScript中访问WebAssembly内存
const buffer = new Uint8Array(memory.buffer);

开发工具链

Emscripten

Emscripten是最流行的C/C++到WebAssembly编译器:

# 安装Emscripten
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest

# 编译C代码到WebAssembly
emcc hello.c -o hello.html

Rust和wasm-pack

Rust语言对WebAssembly有很好的支持:

// lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}
# 使用wasm-pack构建
wasm-pack build --target web

AssemblyScript

AssemblyScript是一种类似TypeScript的语言,专门用于编译到WebAssembly:

// assembly/index.ts
export function add(a: i32, b: i32): i32 {
  return a + b;
}

性能优化

编译优化

WebAssembly的性能优势主要体现在:

  • 快速启动:二进制格式解析速度快
  • 高效执行:接近原生代码性能
  • 内存安全:沙箱环境保证安全性
  • 并行编译:支持多线程编译

内存管理优化

// 避免频繁内存分配
const memory = new WebAssembly.Memory({ initial: 256 });
const buffer = new Uint8Array(memory.buffer);

// 重用内存缓冲区
function processData(data) {
  // 将数据写入共享内存
  buffer.set(data);
  // 调用WebAssembly函数处理
  return wasmModule.instance.exports.process();
}

函数调用优化

减少JavaScript和WebAssembly之间的调用开销:

// 批量处理数据,减少调用次数
function batchProcess(items) {
  // 将所有数据写入内存
  writeDataToMemory(items);
  // 一次调用处理所有数据
  return wasmModule.instance.exports.batchProcess(items.length);
}

实际应用场景

图像处理

WebAssembly在图像处理方面表现出色:

// 使用WebAssembly进行图像滤镜处理
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = wasmFilters.applyBlur(imageData.data, radius);

游戏开发

许多高性能Web游戏使用WebAssembly:

// Unity WebGL导出使用WebAssembly
const unityInstance = UnityLoader.instantiate(
  "gameContainer",
  "Build/game.json",
  { onProgress: unityProgress }
);

科学计算

WebAssembly适合进行复杂的数学计算:

// 使用WebAssembly进行矩阵运算
const matrixA = new Float32Array([1, 2, 3, 4]);
const matrixB = new Float32Array([5, 6, 7, 8]);
const result = wasmMath.matrixMultiply(matrixA, matrixB);

加密算法

WebAssembly可以高效执行加密操作:

// 使用WebAssembly进行哈希计算
const data = new TextEncoder().encode("Hello World");
const hash = wasmCrypto.sha256(data);

调试和测试

开发工具

现代浏览器提供了强大的WebAssembly调试工具:

  • Chrome DevTools:可以查看WebAssembly源码和调试信息
  • Firefox Developer Tools:支持WebAssembly调试和性能分析
  • WebAssembly Studio:在线IDE,支持实时编译和调试

性能分析

使用浏览器的性能分析工具:

// 使用Performance API分析WebAssembly性能
performance.mark('wasm-start');
wasmModule.exports.complexCalculation();
performance.mark('wasm-end');
performance.measure('wasm-execution', 'wasm-start', 'wasm-end');

安全考虑

沙箱安全

WebAssembly运行在严格的沙箱环境中:

  • 内存隔离:无法直接访问系统内存
  • API限制:只能通过导入的函数访问外部资源
  • 类型安全:防止缓冲区溢出等内存错误

输入验证

在调用WebAssembly函数前验证输入:

function safeWasmCall(input) {
  // 验证输入参数
  if (typeof input !== 'number' || input < 0) {
    throw new Error('Invalid input');
  }
  
  // 调用WebAssembly函数
  return wasmModule.instance.exports.process(input);
}

未来发展趋势

接口类型(Interface Types)

WebAssembly Interface Types将允许模块之间更丰富的交互:

(module
  (import "env" "log" (func $log (param string)))
  (export "greet" (func $greet (param string) (result string)))
)

多线程支持

WebAssembly正在增加对多线程的支持:

// 使用Web Workers和SharedArrayBuffer
const worker = new Worker('wasm-worker.js');
const sharedMemory = new WebAssembly.Memory({
  initial: 256,
  maximum: 256,
  shared: true
});

组件模型

WebAssembly组件模型将提供更好的模块化和可组合性:

(component
  (import "logger" (func (param string)))
  (export "process" (func (param string) (result string)))
)

最佳实践

模块设计

设计WebAssembly模块时应考虑:

  1. 单一职责:每个模块专注于特定功能
  2. 接口清晰:明确导入和导出的函数
  3. 内存管理:合理规划内存使用
  4. 错误处理:提供清晰的错误信息

性能优化建议

  1. 减少JS-WASM互操作:批量处理数据
  2. 合理使用内存:避免频繁分配和释放
  3. 利用SIMD:使用单指令多数据操作
  4. 缓存编译结果:避免重复编译

兼容性处理

// 检测WebAssembly支持
if (!WebAssembly) {
  console.error('WebAssembly is not supported');
  // 提供降级方案
}

// 异步加载WebAssembly
async function loadWasm() {
  try {
    const wasmModule = await WebAssembly.instantiateStreaming(
      fetch('module.wasm')
    );
    return wasmModule.instance.exports;
  } catch (error) {
    console.error('Failed to load WebAssembly module:', error);
    return null;
  }
}

总结

WebAssembly作为现代Web平台的重要组成部分,为开发者提供了前所未有的性能和功能。通过将C、C++、Rust等语言编译为WebAssembly,我们可以在浏览器中运行接近原生性能的代码。

随着技术的不断发展,WebAssembly将在更多领域发挥作用,包括边缘计算、物联网、区块链等。掌握WebAssembly不仅能够提升现有Web应用的性能,还能为未来的Web开发开辟新的可能性。

对于前端开发者来说,学习WebAssembly是顺应技术发展趋势的明智选择。通过合理运用WebAssembly,我们可以构建出性能更优、功能更强的Web应用,为用户提供更好的体验。

从最简单的 icon组件开始了解Element-Plus 源码

作者 Nayana
2025年10月9日 15:57

从基础的组件看看Element-plus实现组建的基本流程

目录 packages\components\icon

icon目录.png

因为我们分析源码的目的是学习如何构建组件库已经封装复用性强的组件所以跳过.tsx文件从src目录开始看。

icon.ts
import { buildProps, definePropType } from '@element-plus/utils'

buildProps,definePropType(export const definePropType = <T>(val: any): PropType<T> => val)。definePropType定义了泛型的props类型,buildProps是对 element 组件开发过程中,大家编码时的 Props 的设置,进行了统一格式化。

buildProps.webp

import type { ExtractPropTypes } from 'vue'
import type Icon from './icon.vue'

使用 import 的话,TypeScript 是无法判断你是想导出类型还是一个 JavaScript 的方法或者变量。
所以 TypeScript 提供了 import type or export type,用来明确表示我引入/导出的是一个类型,而不是一个变量或者方法。

定义组件需要的参数
export const iconProps = buildProps({

  /**
   * @description SVG icon size, size x size
   */
  size: {
    type: definePropType<number | string>([Number, String]),
  },
  /**
   * @description SVG tag's fill attribute
   */
  color: {
    type: String,
  },
} as const)

as const 则可以快速将一个对象变成只读类型,常量断言可以把一个值标记为一个不可篡改的常量,从而让 TS 以最严格的策略来进行类型推断。

export type IconProps = ExtractPropTypes<typeof iconProps>

vue3 string构造函数 在TS类型中是他的构造函数类型:StringConstructor 并不是我们想要的,我们希望string构造函数返回的是字符串类型string. vue3提供了prop类型申明ExtractPropTypes 用于接收一个类型用yu把接收的prop类型返回出来;也可以把构造函数类型转换为对应的类型如:StringConstructor 转换成 string

export type IconInstance = InstanceType<typeof Icon> & unknown

返回SFC 实例类型,InstanceType 函数:该函数返回(构造) 由某个构造函数构造出来的实例类型组成的类型

icon/index.ts
import { withInstall } from '@element-plus/utils'

import Icon from './src/icon.vue'

import type { SFCWithInstall } from '@element-plus/utils'
//组件类型引入
export const ElIcon: SFCWithInstall<typeof Icon> = withInstall(Icon)
// 通过 withInstall 方法给 Icon 添加了一个 install 方法
export default ElIcon
// 导出 iCON 组件

export * from './src/icon'

至此,对icon组件的分析就结束了。涉及的withInstall是组件库注册的公共方法。

具体目录是packages\utils\vue\install.ts


export const withInstall = <T, E extends Record<string, any>>(
  main: T,
  extra?: E
) => {
  ;(main as SFCWithInstall<T>).install = (app): void => {
    for (const comp of [main, ...Object.values(extra ?? {})]) {
      app.component(comp.name, comp)
    }
  }

  if (extra) {
    for (const [key, comp] of Object.entries(extra)) {
      ;(main as any)[key] = comp
    }
  }
  return main as SFCWithInstall<T> & E
}
// 组件注册的公共方法
 传递两个参数,main类型为泛型T,extra是一个对象,通过Object.values 将 extra 中的属性值提取为一个数组,并进行遍历进行 组件的注册.
 
 如果extra不为空则通过 迭代器遍历 Object.entries 转换后的 二维数组, 将extra所有属性和值 挂载到 main 对象下
 
 SFCWithInstall 通过泛型 将最后返回的 main 的类型 定义为 T & Plugin & E的交叉类型,为并且关系

vue提高技术 高级语法相关

2025年10月9日 15:30

1. 组合式 API (Composition API)

Vue 3 引入了 Composition API,它是对 Options API 的补充,提供了更好的逻辑复用和组织方式。

示例:

<script setup>
import { ref, computed, watch } from 'vue'

const count = ref(0)
const doubleCount = computed(() => count.value * 2)

watch(count, (newVal) => {
  console.log(`Count changed to ${newVal}`)
})

function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>

使用 <script setup> 可以简化组件定义,自动暴露顶层绑定给模板使用。


2. 响应式系统的深入理解

  • reactive() 创建深层响应式对象。
  • shallowReactive() 创建浅层响应式对象。
  • readonly() 和 shallowReadonly() 提供只读代理。
  • 自定义 ref 实现细粒度控制。

示例:自定义 ref

js
import { customRef } from 'vue'

function useDebouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => {
    return {
      get() {
        track()
        return value
      },
      set(newValue) {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
          value = newValue
          trigger()
        }, delay)
      }
    }
  })
}

3. Teleport 组件

用于将子节点渲染到 DOM 中的不同位置,常用于模态框、通知等全局组件。

示例:

vue
<Teleport to="body">
  <div class="modal">This is a modal</div>
</Teleport>

4. Suspense 组件(实验性)

允许你在等待异步组件加载时显示后备内容。

示例:

vue
<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

5. 动态组件与 keep-alive 缓存

使用 <component :is="currentComponent"> 动态切换组件,并配合 <keep-alive> 缓存未活动的组件实例。

示例:

vue
<keep-alive>
  <component :is="currentTab"></component>
</keep-alive>

6. 插槽 (Slots) 的高级用法

包括具名插槽、作用域插槽以及动态插槽名称。

示例:作用域插槽

vue
<!-- 子组件 -->
<slot :user="currentUser"></slot>

<!-- 父组件 -->
<ChildComponent v-slot="{ user }">
  Hello, {{ user.name }}
</ChildComponent>

7. Provide / Inject 跨层级通信

用于祖先组件向其所有子孙后代注入数据,避免逐层传递 props。

示例:

js
// 祖先组件
provide('theme', 'dark')

// 后代组件
const theme = inject('theme')

8. 自定义指令

创建具有特定行为的自定义 DOM 指令。

示例:

js
const myDirective = {
  mounted(el, binding) {
    el.style.color = binding.value
  }
}

app.directive('color', myDirective)

9. Transition 和 TransitionGroup 动画

为进入/离开 DOM 的元素添加过渡效果。

示例:

vue
<Transition name="fade">
  <p v-if="show">Hello</p>
</Transition>

CSS 类:

css
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

10. 性能优化技巧

  • 使用 v-memo(Vue 3.2+)缓存复杂计算结果。
  • 列表虚拟滚动减少 DOM 渲染压力。
  • 合理拆分组件防止不必要的重渲染。
  • 使用 markRaw() 避免某些对象被转为响应式。

这些高级特性和语法能够帮助你在开发大型应用时更加得心应手。是否需要针对某个具体功能展开详细说明?

❌
❌