普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月21日首页

大文档也能丝滑流式渲染?试试Markstream-Vue,体验前所未有的Markdown流畅感!

作者 Simon_He
2026年1月21日 18:13

为什么选择Markstream-Vue?只因它“流”得够快!

  • 🚀 真·流式渲染,支持超大文档、实时预览,边写边看不卡顿
  • 🏆 彻底解决传统Markdown组件渲染慢、卡顿、内存暴涨等痛点
  • 🧩 组件化设计,Vue 3项目即插即用,API极简
  • 🎨 支持代码高亮、公式、流程图等丰富内容,体验无缝流畅
  • 🔥 SSR/静态站点/移动端全兼容,性能拉满

真实场景,极致体验

  • 技术文档、知识库、长篇小说、实时协作编辑器……再大的内容都能流畅渲染
  • 支持内容“边加载边渲染”,让用户体验“所见即所得”的极致流畅

3步上手,流式体验立享

  1. 安装依赖
pnpm add markstream-vue
  1. 引入组件
<Markstream :source="longMarkdown" stream />
  1. 享受流式渲染的丝滑体验!

你的Star,是我持续优化的最大动力!

👉 GitHub地址


使用uniapp vue2开发微信小程序时,图片处理插件

2026年1月21日 17:47

vue3处理插件

参考juejin.cn/post/738574…

因为上面的文章中提出的例子在vue2中并不生效, 因此单独写了一个针对vue2使用的loader.

实现1: 通过字符串替换方式处理

这个方式的缺点是较为死板, 无法处理模板字符串和表达式相关, 但是对于src=xxx的类型有较好的匹配

 module.exports = function (source) {
  console.log("----customLoader original content----", source);
  function replaceImageSrcInVue(content) {
    
    content = content.replace(
      /(<template[\s\S]*?>)([\s\S]*?)(<\/template>)/,
      (match, start, middle, end) => {
        // 替换 <image ... src="..." ...>
        const replaced = middle.replace(
          /(<image\b[^>]*?\bsrc=)(['"])([^'"]+)\2/gi,
          (imgMatch, prefix, quote, src) => {
            // 只替换非 http/https 开头的 src
            if (/^https?:\/\//.test(src)) return imgMatch;
            console.log(
              "----customLoader src----",
              imgMatch,
              "  prefix:",
              prefix,
              "   src:",
              src,
            );
            return `${prefix}${quote}${"https://www.xxx.com/"}${src}${quote}`;
          },
        );
        return start + replaced + end;
      },
    );
    return content;
  }
  return replaceImageSrcInVue(source);
};

实现2: 基于ast

这个模式的优点是可以精确匹配到image对应的src属性, 还可以对于绑定src的属性中的模板字符串和字符串类型进行处理, 比如说以下代码, 同时也可以很方便扩展到其他类型的元素中, 比如video等.

:src="isActive ? `${activeHost}/logo.png` : '/staticHost/logo.png'"

依赖编译器版本为yarn add -D @@vue/compiler-sfc@3.5.26

详细实现方式如下:

const compiler = require("@vue/compiler-sfc");

module.exports = function (source) {
  const options = this.getOptions();
  let { publicPath: staticHost, sourceDir } = options || {};
  if (staticHost.endsWith("/")) {
    staticHost = staticHost.slice(0, -1);
  }
  try {
    const sfc = compiler.parse(source, {
      templateParseOptions: { parseMode: "sfc" },
    });
    if (!sfc.descriptor.template) {
      return source;
    }
    let content = sfc.descriptor.template.content;
    const ast = sfc.descriptor.template.ast;
    const tempLen = "<template>".length; // 10, loc是基于整个文件的偏移量,需要减去前面的长度
    const traverseAst = (node) => {
      if (!node) return;
      if (node.children && node.children.length) {
        for (let i = node.children.length - 1; i >= 0; i--) {
          traverseAst(node.children[i]);
        }
      }
      const doReplace = (loc, oldValue) => {
        if (oldValue.startsWith(sourceDir)) {
          const newValue =
            '"' + oldValue.replace(sourceDir, `${staticHost}/`) + '"';
          content =
            content.slice(0, loc.start.offset - tempLen) +
            newValue +
            content.slice(loc.end.offset - tempLen);
        }
      };
      if (node.type === 1 && node.tag === "image") {
        // console.log("Found <image> node:", node);
        const srcAttr = node.props.find(
          (prop) => prop.name === "src" && prop.type === 6,
        );
        if (srcAttr) {
          console.log("Original src value:", srcAttr);
          const srcValue = srcAttr.value.content;
          const loc = srcAttr.value.loc;
          doReplace(loc, srcValue);
        } else {
          const bindSrcAttr = node.props.find(
            (prop) =>
              prop.name === "bind" &&
              prop.type === 7 &&
              prop.rawName === ":src",
          );
          // console.log("Bind src attribute:", bindSrcAttr);
          if (!bindSrcAttr) return;

          const ast = bindSrcAttr.exp.ast;
          const loc = bindSrcAttr.exp.loc;
          // 处理简单的模板字符串情况, 只需要遍历处理template和字符串类型就可以
          // 这里可能包含的类型为三目预算符和逻辑运算符
          const traverseBindAst = (bindNode, loc) => {
            if (!bindNode) return;
            // 逻辑运算符|| 或者 &&
            if (bindNode.type === "LogicalExpression") {
              traverseBindAst(bindNode.right, loc);
              traverseBindAst(bindNode.left, loc);
            } else if (bindNode.type === "ConditionalExpression") {
              // 三目运算符
              traverseBindAst(bindNode.alternate, loc);
              traverseBindAst(bindNode.consequent, loc);
              traverseBindAst(bindNode.test, loc);
            } else if (bindNode.type === "TemplateLiteral") {
              // 模板字符串类型
              if (bindNode.quasis && bindNode.quasis.length > 0) {
                const indexLoc = bindNode.quasis[0].loc;
                const value = bindNode.quasis[0].value.cooked;
                if (value.startsWith(sourceDir)) {
                  const newValue = value.replace(sourceDir, `${staticHost}/`);
                  content =
                    content.slice(
                      0,
                      loc.start.offset - tempLen + indexLoc.start.index - 1,
                    ) + // -1 是因为模板字符串的 ` 符号占位
                    newValue +
                    content.slice(
                      loc.start.offset - tempLen + indexLoc.end.index - 1,
                    );
                }
              }
            } else if (bindNode.type === "StringLiteral") {
              // 字符串类型
              const indexLoc = bindNode.loc;
              const value = bindNode.value;
              if (value.startsWith(sourceDir)) {
                const newValue = value.replace(sourceDir, `${staticHost}/`);
                content =
                  content.slice(
                    0,
                    loc.start.offset - tempLen + indexLoc.start.index, // 这里不减是需要保留 "" 符号
                  ) +
                  newValue +
                  content.slice(
                    loc.start.offset - tempLen + indexLoc.end.index - 2,
                  ); // -2 是因为字符串的 "" 符号占位
              }
            }
          };
          traverseBindAst(ast, loc);
        }
      }
    };
    traverseAst(ast);
    // 替换 template 内容
    const loc = sfc.descriptor.template.loc;
    const newSource = source.slice(0, loc.start.offset) + content + source.slice(loc.end.offset);
    return newSource;
  } catch (err) {
    console.error("Error parsing SFC:", err);
    return source;
  }
}

在vue.config.js中的用法

chainWebpack: (config) => {
      config.module
        .rule("vue")
        .use("vue-loader")
        .end()
        .use("customLoader")
        .loader(path.resolve(__dirname, "./customLoader.js"))
        .options({
          publicPath: "https://xxx.com",
          sourceDir: '/staticHost/',
        })
        .end();
  }

ps

如果遇到报错this.getConfig不存在, 则可以把config配置项写到load.js里面中...

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

2026年1月21日 16:44

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

前端开发中,文件上传功能几乎是每个项目都绕不开的需求。但你是否也曾为对接腾讯云COS、华为云OBS、阿里云OSS而头疼?是否也曾为分片上传、断点续传、进度显示等功能而熬夜加班?

今天,我要向大家推荐一款开箱即用、功能强大的 Vue 云上传组件 —— vue-cloud-upload,它将彻底改变你对文件上传的认知!

✨ 为什么选择 vue-cloud-upload?

🎯 痛点一:三大云平台 SDK 对接繁琐

传统做法:

  • 需要分别学习腾讯云、华为云、阿里云的 SDK 文档
  • 每个平台的 API 调用方式各不相同
  • 临时凭证获取逻辑需要自己实现
  • 代码冗余,维护成本高

vue-cloud-upload 的解决方案:

<template>
  <CloudUpload
    cloudType="tencent"
    :cloudConfig="cloudConfig"
    v-model="fileList"
    @success="handleSuccess"
  />
</template>

<script>
import COS from 'cos-js-sdk-v5';
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  data() {
    return {
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  }
};
</script>

只需三步:

  1. 安装对应云平台的 SDK
  2. 配置云平台参数
  3. 引入组件即可使用!

🎯 痛点二:大文件上传体验差

传统做法:

  • 大文件上传容易失败
  • 网络波动需要重新上传
  • 用户无法看到上传进度
  • 用户体验极差

vue-cloud-upload 的解决方案:

  • 自动分片上传:大文件自动切分成小块上传
  • 断点续传:网络中断后可继续上传,无需重新开始
  • 实时进度显示:上传进度实时更新,用户一目了然
  • 分片大小可配置:根据网络环境灵活调整

🎯 痛点三:文件预览功能缺失

传统做法:

  • 上传后只能看到文件名
  • 无法预览图片、PDF、视频等内容
  • 需要额外开发预览功能
  • 增加开发成本

vue-cloud-upload 的解决方案:

  • 📸 图片预览:支持图片缩放、旋转、全屏查看
  • 📄 PDF 预览:直接在线查看 PDF 文档
  • 🎬 视频播放:内置视频播放器,支持在线播放
  • 🎵 音频播放:支持音频文件在线播放
  • 📝 TXT 预览:文本文件直接查看内容

🌟 核心特性一览

1️⃣ 三大云平台无缝对接

  • 🅰️ 腾讯云 COS
  • 🅱️ 华为云 OBS
  • 🅾️ 阿里云 OSS

2️⃣ 丰富的功能特性

功能 说明
多文件上传 支持同时上传多个文件
拖拽上传 支持拖拽文件到上传区域
文件类型限制 可限制上传文件类型
文件大小限制 可限制单个文件大小
上传进度显示 实时显示上传进度
文件列表管理 支持查看、删除已上传文件
附件回显 支持通过文件 key 回显附件
自定义样式 支持自定义上传组件样式
丰富的事件回调 支持上传成功、失败、进度等事件

3️⃣ 灵活的配置选项

cloudConfig: {
  bucket: "your-bucket",           // 桶名
  region: "ap-guangzhou",          // 地域
  path: "uploads/",                // 上传目录
  getTempCredential: async () => { // 获取临时凭证
    const response = await fetch('/api/sts');
    return await response.json();
  }
}

4️⃣ 多种文件 key 生成策略

  • uuid:使用 UUID 生成唯一文件名
  • name:使用原始文件名
  • uuid+name:使用 UUID + 原始文件名(默认)
  • customKey:自定义函数生成文件 key

📦 快速开始

安装组件

npm install vue-cloud-upload

安装对应云平台 SDK

# 腾讯云 COS
npm install cos-js-sdk-v5

# 华为云 OBS
npm install esdk-obs-browserjs

# 阿里云 OSS
npm install ali-oss

基础使用示例

<template>
  <div>
    <CloudUpload
      cloudType="tencent"
      :cloudConfig="cloudConfig"
      v-model="fileList"
      :multiple="true"
      :limit="5"
      :maxSize="100"
      @success="handleSuccess"
      @error="handleError"
      @progress="handleProgress"
    />
  </div>
</template>

<script>
import COS from 'cos-js-sdk-v5';
import "vue-cloud-upload/dist/vue-cloud-upload.css";
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  components: { CloudUpload },
  data() {
    return {
      fileList: [],
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  },
  methods: {
    async getTempCredential() {
      const response = await fetch('/api/sts');
      return await response.json();
    },
    handleSuccess(result, file) {
      console.log('上传成功:', result.url);
    },
    handleError(error, file) {
      console.error('上传失败:', error);
    },
    handleProgress(percent, file) {
      console.log('上传进度:', percent);
    }
  }
};
</script>

🎨 功能演示

各类文件上传

各类型文件上传.png

上传进度展示

上传进度.png

丰富的参数配置

参数配置.png

视频预览

视频预览.png

图片预览

图片预览.png

PDF 预览

pdf预览.png

💡 实战场景

场景一:企业级文件管理系统

<CloudUpload
  cloudType="aliyun"
  :cloudConfig="cloudConfig"
  v-model="fileList"
  :multiple="true"
  :limit="10"
  :maxSize="500"
  listType="picture-card"
  :previewConfig="{
    image: true,
    pdf: true,
    video: true,
    audio: true
  }"
/>

场景二:图片上传组件

<CloudUpload
  cloudType="tencent"
  :cloudConfig="cloudConfig"
  v-model="imageList"
  accept=".jpg,.jpeg,.png,.gif"
  :maxSize="10"
  listType="picture-card"
  :keyType="'uuid'"
/>

场景三:文档上传组件

<CloudUpload
  cloudType="huawei"
  :cloudConfig="cloudConfig"
  v-model="docList"
  accept=".pdf,.doc,.docx,.xls,.xlsx"
  :maxSize="50"
  listType="text"
/>

🔮 未来规划

组件正在持续迭代中,以下功能正在开发中:

  • 🔄 图片添加水印
  • 🔄 图片无损压缩
  • 🔄 视频首帧截取
  • 🔄 Office 文档在线预览(Word, Excel, PowerPoint)
  • 🔄 更多云存储平台支持

📊 项目数据

  • ⭐ GitHub Stars:持续增长中
  • 📦 NPM 下载量:月下载量稳步上升
  • 🎯 支持平台:腾讯云、华为云、阿里云
  • 📝 文档完善度:详细的使用文档和示例
  • 🐛 问题响应:快速响应和修复

🤝 贡献与支持

如果你觉得这个组件对你有帮助,欢迎:

  • 给项目一个 ⭐️ Star
  • 提交 Issue 和 Pull Request
  • 分享给你的同事和朋友

📚 完整文档

更多详细的使用文档和 API 说明,请查看:

📧 联系方式

商务合作请通过邮箱联系:shazhoulen@outlook.com


vue-cloud-upload —— 让文件上传变得更简单!

如果你正在为文件上传功能而烦恼,不妨试试这个组件,相信它会给你带来惊喜!🎉


相关推荐:

深挖 van-list:一次分页加载问题的完整排查

作者 ursazoo
2026年1月21日 15:16

太长不看版

问题:切换筛选项时,如果不滚动页面,loading 会一直显示,但滚动后再切换就正常。

原因

  1. processingData 判断逻辑有问题:当数据量刚好等于 pageSize 时,错误地判断 finished = false
  2. van-list 的 watch 机制:当 finishedfalse 变成 false 时不会触发加载
  3. 两个问题叠加导致切换筛选后没有触发数据加载

解决方案:修改 processingData 的判断逻辑,用 累积数据量 >= total 代替 list.length < pageSize


背景

最近在做会员详情页,有个余额明细的列表,可以切换筛选项(全部/充值/消费)。看起来很简单的功能,结果遇到了一个莫名其妙的问题。

项目技术栈

  • Vue 3 + Vant 4
  • 分页加载用的 van-list 组件

问题现象

  • 进入页面,默认显示"全部"筛选的数据(10 条,total=10)
  • 不滑动页面,直接点击"充值"或"消费"筛选,一直 loading,没有数据
  • 但如果先滑动页面,再切换筛选,正常

第一次遇到这种问题,完全摸不着头脑。为什么滑不滑动页面会有区别?


临时修复

因为要上线,我需要快速解决这个问题。看了代码发现切换筛选时会调用 resetListParams()

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

我猜测可能是因为没有触发数据加载,于是加了一行手动调用 getList()

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
    getList()  // 加了这一行
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

结果:能用,切换筛选正常了。

但我心里还是觉得不踏实:为什么原本的代码不行?滑动页面后就正常?其他用 van-list 的地方会不会也有这个问题?


深入排查

上线后,我打算搞清楚这个问题。

第一步:重现问题

把之前加的 getList() 注释掉,重新测试:

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
    // getList()  // 注释掉,重现问题
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

问题重现

  • 不滑动页面,直接切换筛选,一直 loading
  • 滑动页面后,再切换筛选,正常

第二步:确认请求没发起

打开开发者工具,Network 面板里确实没有新的请求。所以问题不是请求失败,而是根本没发起请求

第三步:分析为什么滑动就正常

看一下 van-list 的用法:

<van-list
  v-model:loading="state.listLoading"
  :finished="state.finished"
  :offset="30"
  @load="getList"
  :finished-text="state.list?.length ? '加载完成' : ''"
>
  <div
    class="member-item"
    v-for="item in state.list"
    :key="item.orderNo"
  >
    <!-- 列表项内容 -->
  </div>
</van-list>

我知道 van-list 会在滚动到底部时触发 @load 事件加载更多数据。

但现在的问题是:切换筛选后,我已经在 resetListParams() 里设置了 state.finished = false,按理说 van-list 应该要重新加载数据才对。为什么不滚动的话,@load 就不会触发呢?难道 van-list 只能通过滚动来触发加载,没有其他方式吗?

我开始怀疑是 finished 状态的问题。

第四步:检查 finished 的判断逻辑

第一次加载数据的代码:

CustomerApiFetch.customerCardBalanceChangeListPost(params)
  .then((res) => {
    const { total, list } = res  // total=10, list.length=10
    const { data, finished } = processingData(state.list, list, queryParams)
    state.list = [...data]
    state.total = total
    state.finished = finished
  })

processingData 是项目的公共 hook,用于处理分页数据:

export const processingData = (data, list, param) => {
  const newData = param.page === 1 ? list : [...data, ...list]
  const finished = list.length < param?.pageSize  // 判断逻辑
  if (!finished) {
    param.page++
  }
  return { data: newData, finished }
}

发现问题

当第一次加载时:

  • list.length = 10(返回 10 条数据)
  • param.pageSize = 10
  • finished = 10 < 10 = false(错误)

但实际上,total 也是 10,说明数据已经全部加载完了,finished 应该是 true 才对!

第五步:理解 van-list 的触发机制

现在我明白了问题的一半:finished 被错误地判断为 false

但还有一个疑问:为什么滑动页面后就正常了?

我去看了 van-list 的源码(node_modules/vant/lib/list/List.js):

// 第 124 行:监听 props 变化
(0, import_vue.watch)(() => [props.loading, props.finished, props.error], check);

// 第 143 行:监听滚动事件
(0, import_use.useEventListener)("scroll", check, {
  target: scroller,
  passive: true
});

原来 van-list 有两种触发 check() 的方式

  1. watch 监听 props 变化:当 finishedloadingerror 改变时
  2. scroll 事件监听:用户滚动时

再看 check() 函数的实现(第 58-85 行):

const check = () => {
  (0, import_vue.nextTick)(() => {
    if (loading.value || props.finished || props.disabled || props.error ||
        (tabStatus == null ? void 0 : tabStatus.value) === false) {
      return;  // 如果 finished=true,直接返回
    }

    // 计算是否滚动到边缘
    // ...

    if (isReachEdge) {
      loading.value = true;
      emit("update:loading", true);
      emit("load");  // 触发 @load 事件
    }
  });
};

现在全部串起来了

第六步:对比两种情况

情况 1:滑动页面后切换(正常)

1. 第一次加载
   ├─ 返回 10 条数据,total=10
   ├─ processingData 判断:list.length(10) < pageSize(10) = false
   └─ state.finished = false(错误判断)

2. 用户滑动页面
   ├─ 触发 scroll 事件
   ├─ van-list 调用 check()
   ├─ 检测到滚动到底部
   └─ 触发 @load

3. 第二次加载(page=2)
   ├─ 返回空数组(因为只有 10 条数据)
   ├─ processingData 判断:list.length(0) < pageSize(10) = true
   └─ state.finished = true(被意外纠正了)

4. 用户切换筛选
   ├─ resetListParams() 设置 state.finished = false
   ├─ finished: true 变成 false(状态改变了!)
   ├─ van-list 的 watch 被触发
   └─ 自动触发 @load

情况 2:不滑动直接切换(问题)

1. 第一次加载
   ├─ 返回 10 条数据,total=10
   ├─ processingData 判断:list.length(10) < pageSize(10) = false
   └─ state.finished = false(错误判断)

2. 用户直接切换筛选(没有滚动)
   ├─ resetListParams() 设置 state.finished = false
   ├─ finished: false 变成 false(状态没变!)
   ├─ van-list 的 watch 不触发(Vue watch 机制:值没变就不触发)
   ├─ scroll 事件也没有(用户没滚动)
   └─ 没有任何方式触发 @load

3. 结果
   ├─ showLoadingToast() 已经显示
   ├─ 但没有请求发起
   └─ loading 永远不会关闭

搞明白了


根本原因

问题有两个层面:

1. processingData 的判断逻辑有缺陷

const finished = list.length < param?.pageSize

这个判断在以下情况下是错误的

场景 list.length pageSize total 实际状态 判断结果 是否正确
还有数据 10 10 30 未完成 false 正确
刚好加载完 10 10 10 已完成 false 错误
最后一页不足 5 10 15 已完成 true 正确

当返回的数据量刚好等于 pageSize,且已经是全部数据时,finished 会被错误地判断为 false。

2. van-list 的触发机制

van-list 的 @load 有两种触发方式:

触发方式 触发条件 使用场景
watch 监听 finishedloadingerror 状态改变 状态切换(true 和 false 互相切换)
scroll 事件 用户滚动到底部 正常的分页加载

当 finished 从 false 变成 false 时

  • watch 不会触发(Vue 的 watch 机制,值没变就不触发)
  • scroll 也不会触发(用户没滚动)
  • 结果:没有任何方式触发 @load

3. 两个问题叠加

processingData 错误判断
  |
  v
finished = false(应该是 true
  |
  v
用户切换筛选
  |
  v
resetListParams 设置 finished = false
  |
  v
finished: false 变成 false(状态没变)
  |
  v
van-list  watch 不触发
  |
  v
没有 scroll 事件
  |
  v
@load 不触发
  |
  v
一直 loading

解决方案对比

方案 1:手动调用 getList()(临时方案)

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    state.finished = false
    getList()  // 手动调用
  })
  showLoadingToast({ ... })
}

优点

  • 快速修复,立即上线

缺点

  • 治标不治本,finished 的判断还是错的
  • 其他 10 个使用 processingData 的页面也有同样的问题

适用场景:紧急上线,先解决问题


方案 2:修复 processingData 判断逻辑

修改 src/hooks/processingData.ts

/**
 * @function 处理分页数据
 * @param { Array } data 保存的数据
 * @param { Array } list 接口请求回来的数据
 * @param { Object } param 请求接口的分页数据
 * @param { Number } total 数据总数
 * @return { data } 处理后的数据
 * @return { finished } 数据是否全部请求完
 */
export const processingData = (data, list, param, total) => {
  const newData = param.page === 1 ? list : [...data, ...list]
  const finished = newData.length >= total  // 使用累积数据量判断
  if (!finished) {
    param.page++
  }
  return { data: newData, finished }
}

然后更新所有 10 个调用的文件,传入 total 参数:

// 修改前
const { data, finished } = processingData(state.list, list, queryParams)

// 修改后
const { data, finished } = processingData(state.list, list, queryParams, total)

优点

  • 从根源解决问题
  • 所有使用分页加载的页面都受益
  • finished 状态永远准确
  • 不需要在 resetListParams 里手动调用 getList()

缺点

  • 需要修改 10 个文件
  • 需要测试所有相关页面

适用场景:彻底解决问题,消除技术债


方案 3:强制触发 watch(hack 方案)

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1

    // 先设置为 true,再设置为 false,强制触发 watch
    state.finished = true
    nextTick(() => {
      state.finished = false  // true 变成 false,触发 van-list 的 watch
    })
  })
  showLoadingToast({ ... })
}

优点

  • 不需要改 processingData
  • 不需要改其他文件
  • 利用了 van-list 的 watch 机制

缺点

  • 非常 hack,不优雅
  • finished 的判断还是错的
  • 状态闪烁(true 变成 false)可能有副作用

最终选择

我选择方案 2:修复 processingData 判断逻辑


踩坑总结

  1. 公共 hook 的判断逻辑要严谨

    • list.length < pageSize 看起来对,但有边界情况
    • 应该用 累积数据量 >= total 来判断
    • 边界情况很容易被忽略
  2. 了解组件库的触发机制很重要

    • 不要只会用,要知道原理
    • van-list 的 watch + scroll 两种触发方式
    • 状态改变和滚动事件的区别
  3. 临时方案要知道只是临时的

    • 昨晚加 getList() 是为了上线
    • 但不能一直用临时方案
    • 要找时间深入研究,彻底解决

一些想法

昨晚为了赶上线,我就直接加了 getList() 就完事了。当时就想着"能用就行",但心里总觉得哪里不对。

今天重新看这个问题,发现还挺有意思的:

  • processingData 的判断逻辑有问题
  • van-list 的 watch 机制
  • 两个问题叠加就出现了

要不是"滑动就正常"这个线索,我估计还得调试更久。就是这个奇怪的现象让我发现,滑动前后 finished 的状态不一样,顺着这个思路才找到根本原因。

还有就是看源码真的有用。之前我就只会用 van-list,知道有 finished@load,但完全不知道它内部怎么工作的。看了源码才明白 watch 和 scroll 两种触发方式,也搞清楚了为什么 false 变成 false 不会触发。

关于临时方案和彻底修复,我觉得都需要吧。昨晚的临时方案让我能按时上线,今天的深入研究让我理解了问题本质。不能因为有临时方案就不去研究,也不能因为追求完美就一直不上线。

深入浅出 TinyEditor 富文本编辑器系列4:基础使用示例

2026年1月21日 14:52

你好,我是 Kagol,个人公众号:前端开源星球

TinyEditor 是一个基于 Quill 2.0 的富文本编辑器,在 Quill 基础上扩展了丰富的模块和格式,功能强大、开箱即用。

本文是《深入浅出 TinyEditor 富文本编辑器系列》文章的第4篇,主要介绍 TinyEditor 的基础使用示例。

本文提供了 TinyEditor 入门的综合示例,涵盖了面向初学者开发者的基本实现模式和常见用例。

快速开始实现

初始化 TinyEditor 最基本的方法是通过容器元素和配置选项创建新实例:

import FluentEditor from '@opentiny/fluent-editor'
 
const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['bold', 'italic', 'underline'],
      ['link', 'blockquote'],
      [{ list: 'ordered' }, { list: 'bullet' }]
    ]
  }
})

核心架构概述

TinyEditor 扩展了 Quill.js,提供了增强的模块和功能。该架构采用模块化设计,每个功能都作为独立模块实现:

image.png

基本配置示例

基本文本编辑器设置

创建带有基本格式化工具的简单文本编辑器:

const basicEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['undo', 'redo'],
      ['bold', 'italic', 'strike', 'underline'],
      [{ script: 'super' }, { script: 'sub' }],
      [{ color: [] }, { background: [] }],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'blockquote', 'code-block']
    ]
  }
})

带表格的高级编辑器

对于需要表格功能的更复杂文档:

import { generateTableUp } from '@opentiny/fluent-editor'
import { defaultCustomSelect,TableMenuSelect, TableSelection, TableUp } from 'quill-table-up'
 
FluentEditor.register({ 'modules/table-up': generateTableUp(TableUp) }, true)
 
const advancedEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['undo', 'redo', 'format-painter', 'clean'],
      [
        { header: [false, 1, 2, 3, 4, 5, 6] },
        { size: ['12px', '14px', '16px', '18px', '24px', '32px'] },
        'bold', 'italic', 'strike', 'underline'
      ],
      [{ color: [] }, { background: [] }],
      [{ align: ['', 'center', 'right', 'justify'] }],
      [{ 'table-up': [] }],
      ['link', 'blockquote']
    ],
    'table-up': {
      customSelect: defaultCustomSelect,
      modules: [
        { module: TableSelection },
        { module: TableMenuSelect },
      ],
    },
  }
})

模块配置模式

协同编辑设置

通过 WebSocket provider 启用实时协作:

FluentEditor.register('modules/collaborative-editing', CollaborationModule, true)
 
const collaborativeEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
      'collaborative-editing': {
        deps: {
          Y,
          Awareness,
          QuillBinding,
          QuillCursors,
          WebsocketProvider,
          IndexeddbPersistence,
        },
        provider: {
          type: 'websocket',
          options: {
            serverUrl: 'wss://ai.opentiny.design/tiny-editor/',
            roomName: ROOM_NAME,
          },
        },
        awareness: {
          state: {
            name: `userId:${Math.random().toString(36).substring(2, 15)}`,
            color: `rgb(${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)})`,
          },
        },
        cursors: {
          template: `
              <span class="${CURSOR_CLASSES.SELECTION_CLASS}"></span>
              <span class="${CURSOR_CLASSES.CARET_CONTAINER_CLASS}">
                <span class="${CURSOR_CLASSES.CARET_CLASS}"></span>
              </span>
              <div class="${CURSOR_CLASSES.FLAG_CLASS}">
                <small class="${CURSOR_CLASSES.NAME_CLASS}"></small>
              </div>
          `,
          hideDelayMs: 500,
          hideSpeedMs: 300,
          transformOnTextChange: true,
        },
      },
  }
})

文件上传配置

配置带有自定义 MIME 类型限制的文件上传:

const editorWithUpload = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['bold', 'italic'],
      ['image', 'video', 'link']
    ],
    'uploader': {
      mimetypes: [
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf'
      ],
      handler(range: Range, files: File[]) {
        return files.map((_, i) => i % 2 === 0 ? false : 'https://developer.mozilla.org/static/media/chrome.5e791c51c323fbb93c31.svg')
      },
      fail(file: File, range: Range) {
        this.quill.updateContents(new Delta().retain(range.index).delete(1).insert({ image: 'https://developer.mozilla.org/static/media/edge.741dffaf92fcae238b84.svg' }))
      },
    },
  }
})

常见使用场景

内容初始化

创建编辑器时设置初始内容:

const initialContent = `
<h1>Document Title</h1>
<p>This is a <strong>sample</strong> document with <em>formatted</em> text.</p>
<ul>
  <li>First item</li>
  <li>Second item</li>
</ul>
<blockquote>Important quote here</blockquote>
`
 
const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: ['bold', 'italic', 'blockquote']
  }
})
 
// 在初始化后设置内容
editor.clipboard.dangerouslyPasteHTML(0, initialContent)

事件处理

监听编辑器事件以实现自定义功能:

const editor = new FluentEditor('#editor', {
  theme: 'snow'
})
 
// 监听文本变化
editor.on('text-change', (delta, oldDelta, source) => {
  console.log('Text changed:', delta)
})
 
// 监听选择变化
editor.on('selection-change', (range, oldRange, source) => {
  if (range) {
    console.log('User selected text:', range)
  } else {
    console.log('User lost focus')
  }
})

样式与主题

自定义主题应用

使用 snow 主题应用自定义样式:

const styledEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline'],
      [{ color: [] }, { background: [] }]
    ]
  },
  placeholder: 'Start typing your document...'
})

国际化设置

配置多语言支持:

const i18nEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    'i18n': {
      lang: 'zh-CN',
      fallback: 'en-US'
    },
    toolbar: ['bold', 'italic', 'link']
  }
})
 
// 动态切换语言
editor.getModule('i18n').setLanguage('en-US')

集成示例

Vue.js 集成

<template>
  <div>
    <div ref="editorRef" class="editor-container"></div>
  </div>
</template>
 
<script setup>
import { ref, onMounted } from 'vue'
import FluentEditor from '@opentiny/fluent-editor'
 
const editorRef = ref()
let editor
 
onMounted(() => {
  editor = new FluentEditor(editorRef.value, {
    theme: 'snow',
    modules: {
      toolbar: ['bold', 'italic', 'link']
    }
  })
})
</script>

React 集成

import { useEffect, useRef } from 'react'
import FluentEditor from '@opentiny/fluent-editor'
 
function EditorComponent() {
  const editorRef = useRef()
  const editorInstanceRef = useRef()
 
  useEffect(() => {
    editorInstanceRef.current = new FluentEditor(editorRef.current, {
      theme: 'snow',
      modules: {
        toolbar: ['bold', 'italic', 'link']
      }
    })
 
    return () => {
      editorInstanceRef.current = null
    }
  }, [])
 
  return <div ref={editorRef} className="editor-container" />
}

最佳实践

  1. 始终指定主题 - 'snow' 主题提供默认 UI
  2. 配置工具栏模块 - 定义用户可用的工具
  3. 处理内容初始化 - 在编辑器创建后设置初始内容
  4. 实现事件监听器 - 响应用户交互和内容变化
  5. 使用适当的清理 - 卸载组件时销毁编辑器实例

这些示例为使用 TinyEditor 构建复杂的富文本应用程序提供了基础。从基本设置开始,根据需要逐步添加更复杂的功能。

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-editor

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

Vue3中如何优雅实现支持多绑定变量和修饰符的双向绑定组件?

作者 kknone
2026年1月21日 10:06

一、自定义input/select等基础表单组件(v-model配合props/emit)

1.1 双向绑定的核心原理

Vue3中组件的双向绑定本质是propsemit的语法糖。在Vue3.4+版本,官方推荐使用defineModel()宏简化实现,而低版本则需要手动处理属性与事件的传递。

1.2 自定义Input组件

方式一:使用defineModel宏(Vue3.4+推荐)

<!-- CustomInput.vue -->
<script setup>
// defineModel自动处理props和emit的双向绑定
const model = defineModel()
</script>

<template>
  <input 
    v-model="model" 
    placeholder="请输入内容" 
    class="custom-input"
  />
</template>

<style scoped>
.custom-input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}
</style>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const inputValue = ref('')
</script>

<template>
  <div>
    <CustomInput v-model="inputValue" />
    <p class="mt-2">输入结果:{{ inputValue }}</p>
  </div>
</template>

方式二:手动处理props与emit(兼容低版本)

<!-- CustomInputLegacy.vue -->
<script setup>
// 接收父组件传递的value
const props = defineProps(['modelValue'])
// 定义更新事件
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input 
    :value="props.modelValue" 
    @input="emit('update:modelValue', $event.target.value)"
    placeholder="请输入内容"
    class="custom-input"
  />
</template>

父组件使用方式与defineModel版本完全一致。

1.3 自定义Select组件

<!-- CustomSelect.vue -->
<script setup>
const model = defineModel()
// 接收选项配置
const props = defineProps({
  options: {
    type: Array,
    required: true,
    default: () => []
  },
  placeholder: {
    type: String,
    default: '请选择'
  }
})
</script>

<template>
  <select v-model="model" class="custom-select">
    <option value="" disabled>{{ props.placeholder }}</option>
    <option 
      v-for="option in props.options" 
      :key="option.value" 
      :value="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

<style scoped>
.custom-select {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  background-color: white;
}
</style>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CustomSelect from './CustomSelect.vue'

const selectedValue = ref('')
const selectOptions = [
  { value: 'vue', label: 'Vue.js' },
  { value: 'react', label: 'React' },
  { value: 'angular', label: 'Angular' }
]
</script>

<template>
  <div>
    <CustomSelect 
      v-model="selectedValue" 
      :options="selectOptions" 
      placeholder="选择前端框架"
    />
    <p class="mt-2">选中值:{{ selectedValue }}</p>
  </div>
</template>

1.4 多v-model绑定

Vue3支持在单个组件上绑定多个v-model,通过指定参数区分:

<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <div class="flex gap-2">
    <input v-model="firstName" placeholder="姓" class="custom-input" />
    <input v-model="lastName" placeholder="名" class="custom-input" />
  </div>
</template>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'

const userFirstName = ref('')
const userLastName = ref('')
</script>

<template>
  <div>
    <UserForm 
      v-model:first-name="userFirstName" 
      v-model:last-name="userLastName" 
    />
    <p class="mt-2">姓名:{{ userFirstName }} {{ userLastName }}</p>
  </div>
</template>

1.5 处理v-model修饰符

自定义组件也可以支持v-model修饰符,比如实现首字母大写:

<!-- CustomInputWithModifier.vue -->
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    // 处理capitalize修饰符
    if (modifiers.capitalize && value) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

<template>
  <input v-model="model" placeholder="请输入内容" class="custom-input" />
</template>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import CustomInputWithModifier from './CustomInputWithModifier.vue'

const inputValue = ref('')
</script>

<template>
  <div>
    <CustomInputWithModifier v-model.capitalize="inputValue" />
    <p class="mt-2">处理后的值:{{ inputValue }}</p>
  </div>
</template>

二、复合表单组件的封装(如带验证的输入框、日期选择器)

2.1 带验证的输入框

往期文章归档
免费好用的热门在线工具

封装一个集成验证逻辑的输入框组件,支持多种验证规则:

<!-- ValidatedInput.vue -->
<script setup>
import { ref, computed } from 'vue'
const model = defineModel()
const props = defineProps({
  rules: {
    type: Object,
    default: () => ({})
  },
  label: {
    type: String,
    default: ''
  }
})

const showError = ref(false)
const errorMessage = ref('')

// 验证输入值
const validate = (value) => {
  showError.value = false
  errorMessage.value = ''

  // 必填验证
  if (props.rules.required && !value) {
    showError.value = true
    errorMessage.value = props.rules.requiredMessage || '此字段为必填项'
    return false
  }

  // 最小长度验证
  if (props.rules.minLength && value.length < props.rules.minLength) {
    showError.value = true
    errorMessage.value = props.rules.minLengthMessage || 
      `最少需要输入${props.rules.minLength}个字符`
    return false
  }

  // 邮箱格式验证
  if (props.rules.email && value) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(value)) {
      showError.value = true
      errorMessage.value = props.rules.emailMessage || '请输入有效的邮箱地址'
      return false
    }
  }

  return true
}

// 失去焦点时触发验证
const handleBlur = () => {
  validate(model.value)
}

// 输入时清除错误提示
const handleInput = () => {
  showError.value = false
  errorMessage.value = ''
}
</script>

<template>
  <div class="validated-input">
    <label v-if="props.label" class="input-label">{{ props.label }}</label>
    <input 
      v-model="model" 
      @blur="handleBlur" 
      @input="handleInput"
      :class="{ 'input-error': showError }"
      class="custom-input"
      :placeholder="props.label || '请输入内容'"
    />
    <div v-if="showError" class="error-message">{{ errorMessage }}</div>
  </div>
</template>

<style scoped>
.validated-input {
  margin-bottom: 16px;
}
.input-label {
  display: block;
  margin-bottom: 4px;
  font-size: 14px;
  font-weight: 500;
}
.input-error {
  border-color: #ff4d4f;
}
.error-message {
  margin-top: 4px;
  font-size: 12px;
  color: #ff4d4f;
}
</style>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import ValidatedInput from './ValidatedInput.vue'

const email = ref('')
const emailRules = {
  required: true,
  requiredMessage: '邮箱不能为空',
  email: true,
  emailMessage: '请输入有效的邮箱地址'
}
</script>

<template>
  <ValidatedInput 
    v-model="email" 
    label="邮箱地址" 
    :rules="emailRules" 
  />
</template>

2.2 日期选择器组件

封装一个支持格式化和范围选择的日期选择器:

<!-- DatePicker.vue -->
<script setup>
import { ref, computed } from 'vue'
const model = defineModel()
const props = defineProps({
  format: {
    type: String,
    default: 'YYYY-MM-DD'
  },
  placeholder: {
    type: String,
    default: '选择日期'
  }
})

// 格式化显示的日期
const formattedDate = computed(() => {
  if (!model.value) return ''
  const date = new Date(model.value)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${year}-${month}-${day}`
})

// 处理日期变化
const handleDateChange = (e) => {
  model.value = e.target.value
}
</script>

<template>
  <div class="date-picker">
    <input 
      type="date" 
      :value="formattedDate" 
      @change="handleDateChange"
      :placeholder="props.placeholder"
      class="custom-input"
    />
    <p v-if="model.value" class="mt-2">选中日期:{{ formattedDate }}</p>
  </div>
</template>

父组件使用:

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import DatePicker from './DatePicker.vue'

const selectedDate = ref('')
</script>

<template>
  <DatePicker v-model="selectedDate" />
</template>

三、表单组件库的设计思路(扩展性与通用性)

3.1 可配置化设计原则

  1. 原子化props设计:将组件的每个可配置项拆分为独立props,如placeholderdisabledsize
  2. 默认值与覆盖机制:为props提供合理默认值,同时允许用户通过props覆盖
  3. 类型安全:使用TypeScript定义props类型,提供更好的开发体验

3.2 插槽的灵活运用

通过插槽增强组件的扩展性:

<!-- CustomInputWithSlot.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <div class="input-group">
    <slot name="prefix"></slot>
    <input v-model="model" class="custom-input" />
    <slot name="suffix"></slot>
  </div>
</template>

父组件使用插槽:

<CustomInputWithSlot v-model="value">
  <template #prefix>
    <span class="prefix-icon">📧</span>
  </template>
  <template #suffix>
    <button @click="clearInput">清除</button>
  </template>
</CustomInputWithSlot>

3.3 样式定制方案

  1. CSS变量主题:使用CSS变量定义主题色、间距等
:root {
  --input-border-color: #ddd;
  --input-focus-color: #409eff;
  --input-error-color: #ff4d4f;
}
  1. 类名穿透:允许用户通过class props传递自定义样式类
  2. Scoped样式与全局样式结合:组件内部使用scoped样式,同时提供全局样式类供用户覆盖

3.4 事件系统设计

  1. 原生事件透传:使用v-bind="$attrs"透传原生事件
  2. 自定义事件:定义组件特有的事件,如validate-successvalidate-fail
  3. 事件命名规范:采用kebab-case命名,如update:model-value

3.5 组件组合策略

  1. 基础组件与复合组件分离:将基础的Input、Button等与复合的Form、FormItem分离
  2. 依赖注入:使用provideinject实现跨组件通信,如表单验证状态的共享
  3. 高阶组件:通过高阶组件增强基础组件的功能,如添加防抖、节流等

课后Quiz

问题1:如何在Vue3中实现组件的双向绑定?请分别写出Vue3.4+和低版本的实现方式。

答案解析

  • Vue3.4+推荐使用defineModel()宏:
<script setup>
const model = defineModel()
</script>
<template>
  <input v-model="model" />
</template>
  • 低版本手动处理props与emit:
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
  <input 
    :value="props.modelValue" 
    @input="emit('update:modelValue', $event.target.value)" 
  />
</template>

父组件统一使用v-model="value"绑定。

问题2:如何让自定义组件支持多个v-model绑定?请给出示例代码。

答案解析: 通过为defineModel()指定参数实现多v-model绑定:

<!-- 子组件 -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
  <input v-model="firstName" placeholder="姓" />
  <input v-model="lastName" placeholder="名" />
</template>

父组件使用:

<CustomComponent 
  v-model:first-name="userFirstName" 
  v-model:last-name="userLastName" 
/>

问题3:在设计表单组件库时,如何保证组件的扩展性和通用性?

答案解析

  1. 可配置props:将组件的每个可配置项拆分为独立props,提供合理默认值
  2. 插槽机制:使用插槽允许用户插入自定义内容
  3. 样式定制:使用CSS变量、类名穿透等方式支持样式定制
  4. 事件透传:透传原生事件,同时定义自定义事件
  5. 组合设计:基础组件与复合组件分离,使用依赖注入和高阶组件增强功能

常见报错解决方案

报错1:[Vue warn]: Missing required prop: "modelValue"

产生原因:自定义组件使用了v-model,但父组件未绑定值,或子组件未正确定义props。 解决办法

  • 确保父组件使用v-model="value"绑定响应式变量
  • 子组件正确使用defineModel()或声明modelValue prop

报错2:[Vue warn]: Invalid prop: type check failed for prop "modelValue". Expected String, got Number

产生原因:v-model绑定的变量类型与子组件期望的prop类型不匹配。 解决办法

  • 检查父组件绑定变量的类型,确保与子组件prop类型一致
  • 子组件中使用.number修饰符或在defineModel()中指定类型

报错3:[Vue warn]: Extraneous non-emits event listeners (update:modelValue) were passed to component

产生原因:子组件未声明update:modelValue事件,或使用了片段根节点导致事件无法自动继承。 解决办法

  • 使用defineModel()宏自动处理事件声明
  • 或手动使用defineEmits(['update:modelValue'])声明事件

参考链接

vue3 打包dist后 nginx 代理部署 浏览器单独访问其中一个路由 报404

作者 持续前行
2026年1月21日 09:54

Vue 3 打包部署 Nginx 404 问题解决方案

这是Vue 3 SPA应用部署到Nginx时最常见的问题。当用户直接访问非根路径(如 /about/user/123)时,Nginx会在服务器上查找对应的物理文件,但Vue应用是单页面应用,这些路由实际上是由前端路由控制的。

1. 问题根源

Vue 3应用打包后:

  • 只有 index.html是真实存在的物理文件
  • 其他路由如 /about/contact是前端路由
  • 当直接访问这些路由时,Nginx在服务器上找不到对应的 about.html文件
  • 因此返回 404 错误

2. 完整解决方案

2.1 方案一:Nginx 配置(推荐)

核心思路:让Nginx对所有找不到的路径都返回 index.html,由Vue Router处理路由

server {
    listen 80;
    server_name your-domain.com;  # 你的域名
    root /usr/share/nginx/html;   # Vue打包文件所在目录
    index index.html;

    # 处理Vue路由 - 核心配置
    location / {
        try_files $uri $uri/ /index.html;
    }

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

    # Gzip压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types
        application/atom+xml
        application/geo+json
        application/javascript
        application/x-javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rdf+xml
        application/rss+xml
        application/xhtml+xml
        application/xml
        font/eot
        font/otf
        font/ttf
        image/svg+xml
        text/css
        text/javascript
        text/plain
        text/xml;
}

关键配置说明

  • try_files $uri $uri/ /index.html;:按顺序尝试查找文件

    1. 先找 $uri(请求的路径)
    2. 再找 $uri/(目录)
    3. 都找不到就返回 /index.html(由Vue处理路由)

2.2 方案二:Docker部署配置

如果你使用Docker部署,Nginx配置:

# Dockerfile
FROM nginx:alpine

# 复制打包文件
COPY dist/ /usr/share/nginx/html/

# 复制自定义Nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # 核心配置:处理Vue路由
    location / {
        try_files $uri $uri/ /index.html;
    }

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

2.3 方案三:Vue Router 配置检查

确保你的Vue Router配置正确:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  // 关键:使用createWebHistory,而不是createWebHashHistory
  history: createWebHistory(process.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('@/views/Home.vue')
    },
    {
      path: '/about',
      name: 'About',
      component: () => import('@/views/About.vue')
    },
    {
      path: '/:pathMatch(.*)*',  // 404页面
      name: 'NotFound',
      component: () => import('@/views/NotFound.vue')
    }
  ]
})

export default router

重要:生产环境必须使用 createWebHistory,而不是 createWebHashHistory(URL带#号的那种)。

2.4 方案四:Vue 项目配置检查

检查 vite.config.jsvue.config.js

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  base: '/',  // 确保base路径正确
  build: {
    outDir: 'dist',
    assetsDir: 'assets'
  }
})
// vue.config.js (Vue CLI)
module.exports = {
  publicPath: '/',  // 确保publicPath正确
  outputDir: 'dist',
  assetsDir: 'assets'
}

3. 部署验证步骤

3.1 本地验证打包结果

打包前先验证:

# 打包
npm run build

# 查看dist目录结构
ls -la dist/
# 应该看到类似:
# index.html
# assets/
#   index-xxx.js
#   index-xxx.css

3.2 本地测试部署

可以使用 serve测试打包结果:

# 安装serve
npm install -g serve

# 在dist目录启动服务
serve -s dist

# 访问 http://localhost:3000
# 测试直接访问路由:http://localhost:3000/about

3.3 部署到Nginx后的验证

部署后测试:

  1. 访问根路径http://your-domain.com(应该正常)
  2. 直接访问路由http://your-domain.com/about(应该正常)
  3. 刷新页面http://your-domain.com/about(应该正常)

4. 常见问题排查

4.1 问题:配置了try_files但还是404

排查步骤

  1. 检查Nginx配置是否生效

    nginx -t  # 检查配置语法
    nginx -s reload  # 重新加载配置
    
  2. 检查Nginx错误日志

    tail -f /var/log/nginx/error.log
    
  3. 检查文件权限

    chmod -R 755 /usr/share/nginx/html/
    

4.2 问题:静态资源404

解决方案:确保静态资源路径正确

location /assets/ {
    alias /usr/share/nginx/html/assets/;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

4.3 问题:开发环境正常,生产环境404

原因

  • 开发环境使用webpack-dev-server,自带路由处理
  • 生产环境需要Nginx配置支持

解决:按照上面的Nginx配置进行设置

5. 进阶配置

5.1 子路径部署

如果你的应用部署在子路径下(如 http://domain.com/myapp/):

// vite.config.js
export default defineConfig({
  base: '/myapp/',
  // ...
})
server {
    listen 80;
    server_name domain.com;
    root /usr/share/nginx/html;
    
    location /myapp/ {
        alias /usr/share/nginx/html/;
        try_files $uri $uri/ /myapp/index.html;
        index index.html;
    }
}

5.2 多环境配置

# 开发环境
server {
    listen 8080;
    server_name dev.domain.com;
    # 开发环境配置
}

# 生产环境
server {
    listen 80;
    server_name domain.com;
    # 生产环境配置
}

6. 一键部署脚本

#!/bin/bash
# deploy.sh

echo "开始部署Vue应用..."

# 1. 打包
echo "打包Vue应用..."
npm run build

# 2. 备份旧版本
if [ -d "/usr/share/nginx/html" ]; then
    echo "备份旧版本..."
    tar -czf /tmp/vue-app-backup-$(date +%Y%m%d%H%M%S).tar.gz /usr/share/nginx/html
fi

# 3. 复制新版本
echo "复制新版本到Nginx目录..."
sudo cp -r dist/* /usr/share/nginx/html/

# 4. 设置权限
echo "设置文件权限..."
sudo chown -R nginx:nginx /usr/share/nginx/html/
sudo chmod -R 755 /usr/share/nginx/html/

# 5. 重启Nginx
echo "重启Nginx服务..."
sudo nginx -t && sudo nginx -s reload

echo "部署完成!"

7. 总结

Vue 3 SPA部署404问题的核心解决方案

  1. Nginx配置try_files $uri $uri/ /index.html;
  2. Vue Router配置:使用 createWebHistory
  3. 打包配置:确保 base路径正确
  4. 文件权限:确保Nginx有读取权限

按照上述配置部署后,直接访问任何路由都能正常工作。

**

昨天以前首页

Element Plus SCSS 变量覆盖用法

作者 码农张3
2026年1月20日 16:13

安装依赖

pnpm i sass -D

样式文件

element-plus-vars.scss

// 覆盖变量
@forward "element-plus/theme-chalk/src/common/var.scss" with (
  $colors: (
    "primary": (
      "base": #4080ff,
    ),
    "success": (
      "base": #23c343,
    ),
    "warning": (
      "base": #ff9a2e,
    ),
    "danger": (
      "base": #f76560,
    ),
    "info": (
      "base": #a9aeb8,
    ),
  ),

  $bg-color: (
    "page": #f5f8fd,
  )
);
// 引入 Element Plus 样式(必须在覆盖变量后)
@use "element-plus/theme-chalk/src/index.scss";

element-plus.scss

/**
 * element-plus 组件样式覆盖
 */
// 变量覆盖(必须在最前面)
@use "./element-plus-vars";
// 引入 Element Plus 样式(必须在覆盖变量后)
@use "element-plus/theme-chalk/src/index.scss";
...

导入样式

index.scss

// 重置样式
@use "./reset";

// element-plus
@use "./element-plus";
...

main.js

...
// ===== 样式导入 =====
import "@/assets/styles/index.scss";
...

vite.config.js

...
// 自动导入
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
...
plugins: [
    vue(),
    // 自动导入 Element Plus 组件和函数,无需手动 import
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    // 自动注册 Element Plus 组件,可在模板中直接使用,采用sass样式配色
    Components({
      resolvers: [ElementPlusResolver({ importStyle: "sass" })],
    }),
  ],  
...

Vue组件变量值更新过程记录

作者 cj8140
2026年1月20日 15:45

从 Vue 2.x 源码角度分析将组件变量 a 从空值修改为 1 的完整调用栈如下:


1. 组件初始化阶段

在组件创建时,Vue 会初始化响应式数据:

// 调用栈:
Vue.prototype._init (init.js)
  └── initState (state.js)
      └── initData (state.js)
          └── observe (observer/index.js)
              └── new Observer (observer/index.js)
                  └── walk (observer/index.js)
                      └── defineReactive (observer/index.js) // 为属性 `a` 创建响应式

关键步骤

  • defineReactivea 创建 getter/setter
    • 初始化 dep 实例(依赖收集器)。
    • 通过 Object.defineProperty 重写 a 的访问器:
      Object.defineProperty(obj, key, {
        get() { /* 依赖收集 */ },
        set(newVal) { /* 触发更新 */ }
      })
      

2. 修改 a 的值

执行 this.a = 1 时触发 setter

// 调用栈:
this.a = 1
  └── a 的 setter (defineReactive 内部)
      └── dep.notify() (observer/dep.js)
          └── subs[i].update() (observer/watcher.js)
              └── queueWatcher (scheduler.js)
                  └── nextTick (scheduler.js)
                      └── flushSchedulerQueue (scheduler.js)
                          └── watcher.run (observer/watcher.js)
                              └── watcher.get (observer/watcher.js)
                                  └── 组件重新渲染 (render 函数)

关键步骤详解

  1. setter 触发

    • 检查新值 1 是否与旧值不同(newVal !== oldVal)。
    • 若不同,调用 dep.notify() 通知所有依赖。
  2. dep.notify()

    • 遍历 dep.subs(存储所有订阅该属性的 Watcher)。
    • 调用每个 watcher.update()
  3. watcher.update()

    • 将 Watcher 加入异步队列(queueWatcher)。
    • 通过 nextTick 异步执行更新。
  4. flushSchedulerQueue

    • 遍历队列中的 Watcher,调用 watcher.run()
    • watcher.run()watcher.get() → 重新执行组件的 render 函数。
  5. 重新渲染

    • render 函数执行时访问 a,触发 getter 重新收集依赖。
    • 生成新的虚拟 DOM,对比差异后更新真实 DOM。

3. 依赖收集机制

在首次渲染和后续更新时,getter 负责收集依赖:

// getter 调用栈:
组件访问 a (render 函数)
  └── a 的 getter (defineReactive 内部)
      └── Dep.target (全局唯一 Watcher)
          └── dep.depend() (observer/dep.js)
              └── 将当前 Watcher 添加到 dep.subs

关键点

  • Dep.target:全局唯一变量,指向当前正在执行的 Watcher(如渲染 Watcher)。
  • dep.depend():将当前 Watcher 加入 dep.subs,建立 属性 → Watcher 的依赖关系。

4. 异步更新队列

Vue 使用异步队列合并更新:

// nextTick 流程:
queueWatcher (scheduler.js)
  └── nextTick (util/next-tick.js)
      └── 异步任务 (Promise/MutationObserver/setTimeout)
          └── flushSchedulerQueue (scheduler.js)

优化逻辑

  • 多次修改 a 会被合并为一次更新(避免重复渲染)。
  • 通过 nextTick 确保在 DOM 更新后执行回调。

Vue 3 Proxy 版本的差异

若使用 Vue 3(基于 Proxy):

  1. 初始化:通过 reactive 创建响应式代理。
  2. 修改值:直接触发 Proxy.set 拦截器,后续流程类似(依赖收集、异步更新)。
  3. 核心差异
    • 无需 Object.defineProperty,支持动态属性。
    • 依赖收集通过 Track 操作,更新通过 Trigger 操作。

总结

阶段 核心操作 关键函数/类
初始化 a 创建响应式 getter/setter defineReactiveDep
修改值 触发 setter → 通知依赖 dep.notify()
依赖更新 异步队列合并更新 queueWatchernextTick
重新渲染 执行 render 函数 Watcher.run()

整个流程体现了 Vue 响应式系统的核心:依赖收集getter)和 派发更新setter),通过 异步队列 优化性能。

开发者必看!TinyPro中后台系统最新Springboot上手指南~

2026年1月20日 15:32

本文由TinyPro中后台系统贡献者周泽龙原创。

在长达三个月的开发下,终于TinyPro的Springboot后端版本终于要问世了,在本期内容中我将带大家一步步去搭建整个后端的流程,也将带大家去探索对于最新版本的更改应该如何实现,以及如何使用本项目进行一个二次的开发和探索。 首先我们先要对于TinyPro项目进行一个整体的拉取,去到TinyPro的官方进行拉取,当我们获取到项目以后就可以进行开始今天的项目构建了。

接下来的流程就是对于前端i项目的搭建以及后端的springboot项目的搭建,最后再去介绍咱们新版本里面的一些特性和组件

1.前端部分的搭建

首先要确保咱们安装了Node.js、NPM、TinyCLI接下来就要正式初始化项目了首先我们进行初始化

(1)在命令行输入tiny init pro对项目进行一个初始化具体的流程可以看我的视频介绍

img_v3_02u4_d0333bf1-9980-431d-be2b-dbfe8a3e66cg.jpg
(2)接下来就让我们进入到我们的项目里面,tinyvue的前端代码里面我们首先进行一个项目的依赖的下载大家可以使用npm install进行项目依赖的下载。

(3)当我们项目依赖下载完成后就可以进入到一个启动流程了,使用npm start进行一个项目的启动启动后就会开启3031端口这样就可以看见项目的启动界面了!

img_v3_02u4_000ed57c-e850-4949-9f38-5309dd9e69ag.png

到目前为止我们的前端项目就算正式启动成功了,接下来让我们一起开始启动后端项目

2.后端项目的搭建

首先我们需要确保自己的本地环境里面有jdk17,maven,mysql,redis以及一个自己喜欢的开发软件可以idea或者vscode

好了准备工作做好以后接下来就让我们进入后端的开发和后端二次开发的一个介绍并且我也将带着大家去了解springboot里面的一些设计和里面的一些函数的内容接下来开始吧

项目结构的介绍: 当进入到项目里面的时候我们最直观的可以看见项目的一个整体结构

img_v3_02u4_fefceef2-2098-4f7d-8b32-9895a6f1faag.png
(1)先介绍一下项目的一个配置文件,对于所有的springboot项目上来第一件事就算看配置文件application.properties文件这个文件里面包含了所有项目需要的配置比如:mysql,redis,Springjpa,mybatis-plus(项目里面没有使用,但是基本的配置都配置好了,也就兼容了喜欢使用mybatis-plus的同学)大家可以更具自己的数据库信息和redis进行配置,需要自己填写好数据库的用户名,端口和驱动地址,还有redis的配置信息比如主机地址和端口号

到这里的同学,那就恭喜大家数据服务的配置我们就是做好了,接下来就是对项目的依赖的下载,这块主要涉及到maven的使用,如果还,没有下载maven的同学记得赶快去下载

(2)接下来开始项目依赖的初始化过程,在项目启动的时候,我们需要先对项目的依赖包去官方的仓库里面下载(这块给大家一个提醒,如果下载过慢的同学记得去配置一下maven的国内镜像源进行下载和配置),敲入命令 mvn install进行一个项目依赖的下载。

如果到这里都执行成功,大家就可以正式的启动项目,正式启动项目之前我希望大家可以去查看自己jdk的配置是否是17,因为接下来的必须要使用jdk17了

(3)进入到TinyProApplication文件里面进行启动项目,在这之前需要确保启动了redis和mysql的服务,并且配置好了密码,然后启动项目以后我们就会看到一个提示:

img_v3_02u4_fe85ca7b-734e-404e-87b6-88039cbcd7dg.png
这里就算证明项目的整体正式启动成功了,接下来就开始监听3000端口了。

项目启动成功以后就可以开始进行一个交互了,大家就可以进入到刚才启动的前端项目里面准备进行一个交互,账户和密码都是admin,这块是配置里面预先写好的,如果有人需要修改这个用户和角色名称,可以进到 DataInitializer文件里面找到user配置进行修改

3.二次开发

这个项目中支持二次开发的模块包括:权限管理拒绝策略,以及用户的登录校验初始化配置

img_v3_02u4_2f360650-8906-4132-92e6-72d89be6f99g.png

(1)首先就是项目的权限管理的问题大家可以看见代码里面首先需要权限校验的接口上面都会有一个

6.png
@PermissionAnnotation这个注解里面配置的就是当前接口需要用户所拥有的权限,然后这块里面底层的实现细节在aspect这个目录里面,然后里面就是对于apo的一个使用。如果大家需要给某一个接口增加新的权限大家就可以直接在接口的上面进行一个使用然后写入具体要限制的细节 比如可以写:

7.png
这块就是要求用户必须要有menu::query::list这个权限才能进入到这个接口里面进行查询操作如果大家想更进一步了解到权限管理的细节,可以去看aop的使用java里面的切面编程

(2)接下来可以看拒绝的策略,首先对于接口拒绝策略的具体控制在配置文件里面,大家可以看到

8.PNG\
这块就是一个拒绝策略的开关,如果大家想开始拒绝策略就可以直接输入true这个然后就会开启拒绝策略进行项目模式,目前是默认在演示模式里面

这个里面主要分为一个演示模式和一个项目模式,在项目模式里面大家可以自由的进行控制但是在演示模式里面,有很多的功能都被禁止了,所以大家要是不能使用的话就需要先查看是否是因为在演示模式里面导致的

(3)接下来就是用户的登录校验,大家首先要明白的一个流程就是用户首先要登录,只有登录成功以后才会将token放到redis里面,然后用户登录的校验就会先去redis里面进行查询,如果查询的到就会通过校验,如果redis里面没有当前用户人的信息就会进行一个拒绝的返回,然后就会跳转到前端的登录界面里面进行一个登录。具体就是拿一个拦截器进行拦截然后对每一个请求都进行校验只有登录过的才能进行项目的操作 (4)项目的初始化整个项目的初始化都在DataInitializer.java这个文件里面,如果后续需要进行一个项目的初始化调整,比如更改初始化的顺序以及在初始化的过程中想再加载一些资源都可以在这个文件里面进行增加

9.png

在这个run方法里面进行添加,这样项目在启动的时候就会先去加载项目里面的内容然后生成一个data文件夹的,这就标志着项目以及初始化过了,不需要再进行初始化,接下来每次的项目初始化都会先去看项目里面是否有data的目录如果存在就不走初始化的逻辑了

好了讲解完二次开发以后,接下来就要进入到docker的一个部署流程,在这个之前,大家可以更具的自己的情况去看是去买一个云服务器还是自己搭建一个虚拟机环境,然后进行配置,我在视频里面给搭建演示的就是在自己的虚拟机里面进行一个docker的部署和调用

4.docker部署

首先要了解在进行docker部署的时候,自己的容器文件里面的内容是否创建好了,以及对应的docker-compose.yml的一个配置

再检查完这些内容以后就要进入到我们的一个docker的部署流程环节,其实本质上也很简单就是进入到项目的文件夹目录里面,然后直接执行docker compose up -d这个命令以后,等待下载,但是下载的过程里面会有很多的问题比如下载过慢问题

(1)将项目的文件上传到服务器上面

10.png

然后进入当前目录大家可以看见,项目里面有两个文件一个是Dockerfile另一个是docker-compose.yml着两个文件是我们必须要的文件,进入进去看见

11.png

里面就是一些配置比如mysql的地址以及redis的地址,都是对应着我们即将启动的容器名称

(2)接下来就开始正式的启动docker-compose.yml文件,使用命令docker compose up -d启动成功以后就可以进行前端端口的配置映射到线上的docker地址,方便未来的开发
11.png

这个就是启动成功了,大家可以看映射的地址进行修改前端的配置了

5.本次参加开源之夏的感受和收获

在参加完这次的开源之夏以后,我最大的感受就是第一次有一个整齐的计划和老师还有别的学校的同学们可以一起开发一个软件,让我还没出社会的时候就已经拥有了独立开发的经验和经历。其次就是老师的辅导和社区的教导让我真的成长了很多,我特别感谢开源之夏和+OpenTiny社区对我的帮助,最后谢谢我的导师(真的很牛),他也很耐心的教我,特别感谢名字的话就不说了,不然以后有人烦他去了

谢谢大家我真的很珍惜这次机会,谢谢开源之夏,谢谢OpenTiny社区,谢谢导师,那我的这次开源之旅就结束,但是我相信只是暂时,我以后还会继续投身到开源里面,也希望可以帮助更多的人

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro 源码:github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

我整理了一份 Vue 性能优化指南(给AI用的)

作者 ursazoo
2026年1月19日 17:17

为什么做这个

说实话,这个项目是我自己用的。

工作这几年,遇到的性能问题基本都是类似的坑:接口瀑布流、bundle 越来越大、响应式乱用。每次踩完坑修好了,过段时间换个项目又踩一遍。

后来想着,干脆整理一份文档,自己查方便,也能给 AI 编码助手看(我现在用 Claude Code),这样审代码的时候能提前发现问题。

整理完发现,好像也可以分享出来,说不定有人也遇到过这些问题。

我踩过的坑

这几年写 Vue 项目(Vue 2/3 + Nuxt 都有),踩过不少坑:

接口请求变成瀑布流 一个 await 接一个 await,明明能并行的请求硬是串行了。用户抱怨页面慢,一查发现 3 个接口排队等了 750ms。

bundle 体积失控 每次加需求就往里塞代码,没人关心打包结果。等到首屏白屏 3 秒了,才发现 JavaScript 已经 300KB+。

响应式系统滥用 大对象直接 ref(),上千条商品数据,每个字段都变成响应式。渲染一卡一卡的,还以为是组件写得不好。

这些问题不是什么高深的优化,就是基本功。但忙起来就容易忽略,等出问题再改成本就高了。

怎么说呢,优化要分轻重

我发现很多人(包括以前的我)做性能优化会搞错重点。

举个例子:页面有 600ms 的请求等待时间,结果花一周优化 computed 缓存。首屏加载了 300KB 的 JavaScript,结果去优化循环少跑几次。

其实应该先解决大问题:

  1. 先干掉请求瀑布流 - 能并行就并行,该预加载就预加载
  2. 再砍 bundle 体积 - 代码分割、动态导入、tree-shaking
  3. 然后才是组件和响应式优化 - 减少不必要的渲染

我按这个思路把规则分成了 10 个类别,从 CRITICALLOW,总共 46 条。先把影响大的问题解决了,那些微优化可以慢慢来。

里面有什么

10 个类别,46 条规则:

  • 消除异步瀑布流(CRITICAL)
  • 包体积优化(CRITICAL)
  • 服务端性能(HIGH)
  • 客户端数据获取(HIGH)
  • 响应式系统优化(MEDIUM-HIGH)
  • 渲染性能(MEDIUM)
  • Vue 2 特定优化(MEDIUM)
  • Vue 3 特定优化(MEDIUM)
  • JavaScript 性能(LOW-MEDIUM)
  • 高级模式(LOW)

每条规则的格式:

  • 影响等级(CRITICAL / HIGH / MEDIUM / LOW)
  • 错误示例(我以前写过的错误代码)
  • 正确示例(后来改成什么样)
  • Vue 2/3 兼容性说明

举几个我踩过的坑

坑 1:不需要的 await 也在阻塞代码

以前写过这样的代码:

async function handleRequest(userId: string, skipProcessing: boolean) {
  // 即使 skipProcessing=true,也会等待 userData
  const userData = await fetchUserData(userId)

  if (skipProcessing) {
    // 立即返回,但前面已经浪费时间等待了
    return { skipped: true }
  }

  // 只有这个分支使用 userData
  return processUserData(userData)
}

问题是,即使 skipProcessing=true,还是会去请求 userData。白白浪费时间。

后来改成这样:

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    return { skipped: true }
  }

  // 只在需要时才获取数据
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

其实很简单,但之前就是没注意到。

坑 2:大对象别直接用 ref

1000 条商品数据,每条 10+ 个字段,以前直接 ref()

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

// 1000 个商品,每个商品 10+ 字段,全部变成响应式
const products = ref<Product[]>([])

async function loadProducts() {
  products.value = await fetchProducts()
  // Vue 会递归遍历所有对象,添加响应式代理
}
</script>

渲染的时候卡得要命。后来发现应该用 shallowRef

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

// 只有数组本身是响应式的,内部对象保持普通对象
const products = shallowRef<Product[]>([])

async function loadProducts() {
  // 替换整个数组触发更新,无需深度响应式
  products.value = await fetchProducts()
}
</script>

shallowRef 只让数组本身响应式,内部对象保持普通对象。更新时替换整个数组就能触发响应,省了大量性能开销。

几个真实案例(我遇到过的)

案例 1:别对同一个数组循环多次

之前接手一个项目,发现同一个商品列表循环了 5 次:

// 错误:5 次独立遍历
const discounted = products.filter(p => p.discount > 0)
const inStock = products.filter(p => p.stock > 0)
const featured = products.filter(p => p.featured)
const totalValue = products.reduce((sum, p) => sum + p.price, 0)
const avgPrice = totalValue / products.length

看着就难受。后来改成一次循环:

// 正确:一次遍历
const stats = products.reduce((acc, product) => {
  if (product.discount > 0) acc.discounted.push(product)
  if (product.stock > 0) acc.inStock.push(product)
  if (product.featured) acc.featured.push(product)
  acc.totalValue += product.price
  return acc
}, { discounted: [], inStock: [], featured: [], totalValue: 0 })

const avgPrice = stats.totalValue / products.length

商品少的时候看不出来,数据一多性能差距就很明显了。

案例 2:独立的请求不要排队

用户详情页,三个互不依赖的接口,结果在串行调用:

// 错误:串行:总耗时 = 300ms + 200ms + 250ms = 750ms
const user = await fetchUser(userId)          // 300ms
const posts = await fetchUserPosts(userId)    // 200ms
const comments = await fetchUserComments(userId) // 250ms

改成并行之后:

// 正确:并行:总耗时 = max(300ms, 200ms, 250ms) = 300ms
const [user, posts, comments] = await Promise.all([
  fetchUser(userId),
  fetchUserPosts(userId),
  fetchUserComments(userId)
])

总耗时从 750ms 降到 300ms,页面快了一半多。这种优化投入产出比最高。

案例 3:长列表用 CSS content-visibility

1000+ 条评论的页面,初始渲染很慢:

<!-- 错误:所有评论立即渲染 -->
<div v-for="comment in comments" :key="comment.id">
  <CommentCard :comment="comment" />
</div>

后来加上 content-visibility

<!-- 正确:浏览器跳过屏幕外的渲染 -->
<div
  v-for="comment in comments"
  :key="comment.id"
  class="comment-item"
>
  <CommentCard :comment="comment" />
</div>

<style>
.comment-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 200px;
}
</style>

浏览器会跳过屏幕外的渲染,初始加载快了 5-10 倍,滚动也流畅多了。这个 CSS 属性真的好用。

怎么用

直接看

# 克隆仓库
git clone https://github.com/ursazoo/vue-best-practices.git

# 安装依赖
npm install

# 构建 AGENTS.md
npm run build

克隆下来,直接看 rules/ 目录下的规则文件。每个文件都是独立的,包含问题说明、代码示例和解决方案。

也可以看构建好的 AGENTS.md,把所有规则整合在一起,方便搜索。

集成到 AI 编码助手

如果你也在用 Claude Code、Cursor 这类 AI 工具写代码,可以集成进去:

npx add-skill vue-best-practices

AI 审查代码的时候,如果发现性能问题(比如请求瀑布流、过度响应式),会参考这些规则给出优化建议。我现在就是这么用的,挺方便。

Vue 2 还是 Vue 3

都支持。每条规则都标注了版本兼容性:

  • Vue 2 & 3 通用:基础的性能优化技巧
  • Vue 3 Only:用了 <script setup>shallowRefSuspense 等新特性
  • Vue 2 Only:针对 Vue 2 的特定优化(比如 Object.freeze()

老项目也能用,新项目能用得更充分。

项目地址

GitHub: github.com/ursazoo/vue…

欢迎贡献

这是个开源项目。如果你在生产环境踩过坑、有更好的优化方案,欢迎提 Issue 或 PR。

特别是:

  • 实际项目中遇到的性能问题
  • 现有规则的改进建议
  • Vue/Nuxt 新版本的优化技巧

项目信息

  • 46 条规则,10 个类别
  • 按影响程度排序(先解决大问题)
  • 支持 Vue 2/3 和 Nuxt
  • 适配 AI 编码助手

希望对你有帮助。

开源发布 🚀 | 解决 Vue Hero 动画的最后一块拼图:完美支持 v-show!

作者 JIE_
2026年1月19日 16:50

背景

在前段时间我实现了一个Vue指令,用于实现元素的跨页面动画效果: 【Hero动画】用一个指令实现Vue跨路由/组件动画

但有个遗憾一直没解决:不支持v-show指令。

最近终于有时间了,决定攻克这个技术难题,让 Hero 动画更加完整!

为什么v-show这么棘手🤔

v-if / 路由切换

v-if路由切换的情况下,使用指令的mountedbeforeUnmount钩子非常方便,只需要在挂载时注册Hero元素,在卸载前执行过渡动画即可。

// 这种很简单:挂载时注册,卸载时执行动画
const heroAnimationDirective: Directive<HTMLElement, HeroAnimationProps> = {
  mounted(el, { value }) {
    el.dataset.heroId = value.heroId;
  },
  beforeUnmount(el, { value }) { 
    heroAnimation(el, value);
  }
};

v-show 触发的变化

v-show通过display属性控制显示/隐藏,没有卸载过程,只能通过beforeUpdateupdated钩子来监听元素的变化。 核心难点:如何区分是v-show触发的显示变化,还是其他响应式数据的变化?

解决方案思路

所以我们只能手动判断是否是v-show触发的变化,只有在display属性变化时,才执行过渡动画。 大致实现步骤:

  1. mounted钩子中,将相同heroId的元素注册到一个集合中,标记为v-show组合。
  2. updated钩子中,判断display状态,从而判断是否是v-show触发的变化。

1-1.png

实现

注册Hero元素

我们先定义一个Map,用于存储heroId和对应的v-show元素集合。 并且实现注册和注销函数。

// 元素映射表 用于v-show 元素对的匹配
const heroMap = new Map<string, Set<HTMLElement>>();

/**
 * 注册Hero元素
 * @param el Hero元素
 * @param heroId Hero ID
 */
function registerHero(el: HTMLElement, heroId: string) {
  if (!heroMap.has(heroId)) {
    heroMap.set(heroId, new Set());
  }
  heroMap.get(heroId)?.add(el);
}

/**
 * 注销Hero元素
 * @param el Hero元素
 * @param heroId Hero ID
 */
function unregisterHero(el: HTMLElement, heroId: string) {
  const set = heroMap.get(heroId);
  if (set) {
    set.delete(el);
    if (set.size === 0) heroMap.delete(heroId);
  }
}

除此之外,我们还需要在元素都挂载好之后,来验证每个heroId是否有且只有2个v-show元素。

/**
 * 验证Hero元素对是否匹配
 * @param heroId Hero ID
 */
function validatePair(heroId: string) {
  const set = heroMap.get(heroId);
  if (set) {
    if (set.size === 2) {
      set.forEach(el => {
        const display = getComputedStyle(el).display;
        (el as any).__isVShowPair = true;
        (el as any).__wasHidden = display === 'none';
        // 记录原始display属性
        display !== 'none' && ((el as any).__originDisplay = display);
      });
    } else if (set?.size < 2) {
      set.forEach(el => (el as any).__isVIfPair = true);
      heroMap.delete(heroId);
    } else {
      console.error(`Hero ID "${heroId}" 有 ${set.size} 个元素,预期 2 个`);
    }
  }
}

再在指令处调用方法:

  1. mounted钩子中注册并验证元素对.
  2. updated钩子中判断是否是v-show触发的变化,从而执行过渡动画。
  3. beforeUnmount钩子中注销元素对。
const heroAnimationDirective: Directive<HTMLElement, HeroAnimationProps> = {
  mounted(el, { value }) {
    const heroId = value.heroId;
    el.dataset.heroId = heroId;
    registerHero(el, heroId);

    queueMicrotask(() => validatePair(heroId));
  },
  updated(el, { value }) {
    if (!(el as any).__isVShowPair) return
    const wasHidden = (el as any).__wasHidden;
    const display = getComputedStyle(el).display;
    // 初始display为隐藏的元素触发 避免触发两次
    if (!wasHidden) {
      heroAnimation(el, value);
    }
    // 重新记录隐藏状态
    (el as any).__wasHidden = display === 'none';
    (display !== 'none' && !(el as any).__originDisplay) && ((el as any).__originDisplay = display);
  },
  beforeUnmount(el, { value }) {
    // v-if/路由切换元素触发动画
    if ((el as any).__isVIfPair) {
      heroAnimation(el, value);
    }
    unregisterHero(el, value.heroId);
  }
};

改造动画

因为我们是在updated钩子中执行的动画,这时起始元素display属性已经被改变为none,我们需要先恢复原始值然后再执行动画。

/**
 * 执行元素的动画过渡
 * @param source 起始元素
 * @param props 动画属性
 */
async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 容器
  const containerEl: HTMLElement = isRef(container)
    ? container.value ?? document.body
    : typeof container === 'string'
      ? document.querySelector(container) ?? document.body
      : container;
  const containerRect = getRect(containerEl);

  // v-show 标识
  const isVShowPair = (source as any).__isVShowPair;

  // v-show情况下,需要先显示元素,才能获取到正确的位置信息
  if (isVShowPair) {
    source.style.setProperty('display', (source as any).__originDisplay || 'block');
    await nextTick();
  }

  const rect = getRect(source);
  const clone = source.cloneNode(true) as HTMLElement;

  copyStyles(source, clone);
  // v-show 恢复隐藏
  isVShowPair && source.style.setProperty('display', 'none');
  await nextTick();

  let target: HTMLElement | null = null;

  if (isVShowPair) {
    // 从映射表中获取目标元素
    const set = heroMap.get(heroId);
    set && set.forEach(item => item !== source && (target = item));
  } else {
    target = document.querySelector(
      `[data-hero-id="${heroId}"]:not([data-clone]):not([style*="display: none"])`
    ) as HTMLElement;
  }

  if (!target) return;

  ...先前的动画逻辑
}

简单来个页面测试一下

<template>
  <button @click="flag = !flag">触发</button>
  <div class="container">
    <div
      v-show="flag"
      v-hero="animationProps"
      class="box1"
    />
    <div
      v-show="!flag"
      v-hero="animationProps" 
      class="box2"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue-demi';
import type { HeroAnimationProps } from 'vue-hero-cross';

const flag = ref(false)
const boxRef = ref<HTMLDivElement>()

const animationProps: HeroAnimationProps = {
  heroId: 'box',
  duration: '2s',
  position: 'absolute',
  container: '.container'
}
</script>

<style scoped>
.container {
  position: relative;
  width: 500px;
  height: 500px;
  border: 1px solid #000;
  border-radius: 12px;
  overflow: hidden;
}
.box1 {
  position: absolute;
  top: -50px;
  left: -50px;
  width: 200px;
  height: 200px;
  background-color: red;
  border-radius: 12px;
}

.box2 {
  position: absolute;
  bottom: -50px;
  right: -50px;
  width: 300px;
  height: 300px;
  background-color: blue;
  border-radius: 50%;
  transform: rotate(45deg);
}
</style>

看看效果:

1-2.gif

完美触发过渡😀

细节优化

快速切换优化

想到一个场景,如果快速点击按钮/切换路由,会出现什么效果。

1-3.gif

可以看到连点两下按钮后,虽然只有一个动画再执行,但是目标元素已经变化到了最初的蓝色BOX,但是动画的路径却没有变化,这明显是不符合预期的。 预期效果应该是如果目标元素已经变化了,那么动画的路径也应该变化到新的目标位置。 实现步骤:

  1. 当触发动画时,先判断是否存在正在进行的动画。
  2. 如果存在,需要先中断当前动画,然后创建一个新的动画元素。
  3. 新的动画元素需要复制当前动画元素的所有样式
  4. 新元素的位置需要设置为当前动画元素的位置
  5. 最后,新元素作为起始元素,开始新的动画。

我们先定义一个映射表,用于存储当前正在进行的动画元素。

// 正在进行的动画元素映射表
const animatingMap = new Map<string, HTMLElement>();

然后再实现中断当前动画的逻辑。

async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 中断动画标识
  let isInterruptedAnimation = false;
  // 容器
  const containerEl: HTMLElement = isRef(container)
    ? container.value ?? document.body
    : typeof container === 'string'
      ? document.querySelector(container) ?? document.body
      : container;
  const containerRect = getRect(containerEl);

  // 存在正在进行的动画,需要中断
  if (animatingMap.has(heroId)) {
    // 当前动画元素
    const animatingEl = animatingMap.get(heroId) as HTMLElement;
    const animatingElStyle = window.getComputedStyle(animatingEl);

    // 克隆当前动画元素,用于新的动画
    const newSource = animatingEl.cloneNode(true) as HTMLElement;
    copyStyles(animatingEl, newSource);
    // copyStyles 函数排除了 left、top 样式,手动计算并设置当前动画元素的位置
    newSource.style.left = animatingElStyle.left;
    newSource.style.top = animatingElStyle.top;
    containerEl.appendChild(newSource);

    // 移除旧的动画元素
    containerEl.removeChild(animatingEl);
    
    source = newSource;
    isInterruptedAnimation = true;
  }

  ...

  copyStyles(source, clone);
  // v-show 恢复隐藏
  isVShowPair && source.style.setProperty('display', 'none');
  // 这时候的source是我们手动添加的 现在需要手动移除
  isInterruptedAnimation && containerEl.removeChild(source);
  await nextTick();

  ...

  containerEl.appendChild(clone);
  // 添加动画元素到映射表
  animatingMap.set(heroId, clone);

  requestAnimationFrame(() => {
    ...

    clone.addEventListener('transitionend', () => {
      ...
      // 动画结束后删除
      animatingMap.delete(heroId);
    }, { once: true });
  })
}

再看看现在的效果:

1-4.gif

这下可以实现移动到新的目标位置了😀。

动画时间优化

但这也带来了一个问题,就是动画时间。 现在中断动画后,当前动画元素过渡到新的目标位置还是需要2秒,但这不符合预期。 我们预想一个场景: 假设一个AB的动画,过渡动画时间是2000ms

  1. 前进的途中,动画播放了750ms,用户再次点击了按钮,那当前动画元素应该回到A位置,而过渡时间就是已播放的750ms
  2. 折返的途中,动画播放了500ms,用户再次点击了按钮,那当前动画元素应该回到B位置,而过渡时间就是总播放时长2000ms减去AB已过渡的250ms得到的1750ms

1-6.png

根据这个逻辑,我们需要多记录几个信息:

  1. 动画当前被重播的次数,以此来判断是前进还是折返
  2. 前进的时长,以此来计算继续前进折返的过渡时间。
  3. 动画开始时间,用于计算已播放时长。

我们修改animatingMap的类型,添加这些属性。 再添加一个方法,用于转换duration为毫秒数。

// 正在进行的动画元素映射表
interface AnimatingInfo {
  el: HTMLElement;
  count: number;
  elapsed: number;
  startTime: number;
}
const animatingMap = new Map<string, AnimatingInfo>();

/**
 * 解析动画时长
 * @param d 时长字符串或数字
 * @returns 时长(毫秒)
 */
function parseDuration(d: string | number): number {
  if (typeof d === 'number') return d
  const match = String(d).match(/^([\d.]+)\s*(s|ms)?$/)
  if (!match) return 1000
  const [, n, unit] = match
  return unit === 's' ? parseFloat(n) * 1000 : parseInt(n, 10)
}

我们再改造heroAnimation函数,来实现动画时间优化。

async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 解析时长
  let durationMs = parseDuration(duration);
  ...
  const animatingInfo = animatingMap.get(heroId);
  // 存在正在进行的动画,需要中断
  if (animatingInfo) {
    const timeElapsed = performance.now() - animatingInfo.startTime;
    // 前进 还是 折返
    const isForward = animatingInfo.count % 2 === 0;

    animatingInfo.elapsed = isForward
      ? (animatingInfo.elapsed || 0) - timeElapsed
      : animatingInfo.elapsed + timeElapsed;
    
    durationMs = isForward
      ? durationMs - animatingInfo.elapsed
      : animatingInfo.elapsed;
    
    // 当前动画元素
    const animatingEl = animatingInfo.el;
    const animatingElStyle = window.getComputedStyle(animatingEl);
    ...
  }

  ...

  containerEl.appendChild(clone);
  // 更新动画元素
  const animationData = animatingInfo || {
    el: clone,
    count: 1,
    elapsed: 0,
    startTime: performance.now(),
  }
  if (animatingInfo) {
    animatingInfo.el = clone;
    animatingInfo.count++;
    animatingInfo.startTime = performance.now();
  }
  animatingMap.set(heroId, animationData);

  requestAnimationFrame(() => {
    // 改用转换后的时间
    clone.style.transition = `all ${durationMs}ms ${timingFunction} ${delay}`;
    ...
  });
}

这时我们再看看效果:

1-5.gif

这下动画时间就符合预期了🎉。

源码 和 使用

GitHub仓库

该指令的源码已经上传到github,如果对你有帮助,请点点star⭐: GitHub vue-hero-cross

npm包安装

同时,也发布到了npm,你可以通过npm install vue-hero-cross安装来直接使用: npm vue-hero-cross

🤝 参与贡献

如果你对这个项目感兴趣,欢迎:

  1. 提交 Issue 报告问题或建议。
  2. 提交 PR 添加新功能或修复 Bug。
  3. 在项目中实际使用并反馈体验。
  4. 分享给更多开发者

前端-请求接口中断处理指南

2026年1月19日 16:32

请求中断处理指南

📋 今日处理总结

处理的问题

  1. 实现 eligibleForPrizeExchange 请求中断:当打开 auto select 时,中断正在进行的 eligibleForPrizeExchange 请求
  2. 实现 target-prizes 请求中断:当关闭 auto select 时,中断正在进行的 target-prizes 请求
  3. 解决竞态条件问题:防止旧请求的结果覆盖新请求的结果
  4. 修复 loading 状态管理:确保 loading 状态能正确显示和更新

核心实现逻辑

1. 使用 AbortController + RequestId 双重保障
// 1. 定义状态管理
const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);

// 2. 在请求函数中
const fetchData = async () => {
    // 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 创建新的 AbortController 和 requestId
    const requestId = ++currentRequestId.value;
    const newAbortController = new AbortController();
    abortController.value = newAbortController;
    
    try {
        // 检查 requestId 是否仍然是最新的
        if (requestId !== currentRequestId.value) {
            return; // 已被新请求取代,直接返回
        }
        
        const signal = newAbortController.signal;
        
        // 发送请求,传递 signal
        const result = await apiCall(params, signal);
        
        // 请求完成后,再次检查 requestId
        if (requestId !== currentRequestId.value) {
            return; // 已被新请求取代,忽略结果
        }
        
        // 检查当前状态是否仍然匹配
        if (!shouldProcessResult()) {
            return; // 状态已改变,不处理结果
        }
        
        // 处理结果
        updateData(result);
        
    } catch (error) {
        // 如果是 AbortError,不需要处理错误
        if (error instanceof Error && error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        // 只有当前请求完成时,才清理 AbortController
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
    }
};
2. 在 Store 中传递 signal
// stores/example.ts
async function fetchData(params: any, signal?: AbortSignal) {
    const uniqueKey = `fetchData-${Date.now()}-${Math.random()}`;
    const { data, execute } = useApiFetch(url, {
        method: "POST",
        body: JSON.stringify(params),
        signal: signal,
        key: uniqueKey, // 确保每次创建新实例
    });
    
    await execute();
    
    // 处理结果...
}

// 在 catch 中处理 AbortError
catch (e: any) {
    if (e?.name === 'AbortError' || e?.message?.includes('aborted')) {
        return { success: false, data: null, message: 'Request aborted' };
    }
    // 处理其他错误...
}
3. 在 useApiFetch 中动态设置 signal
// composables/useApiFetch.ts
onRequest({ options }) {
    if (signal !== undefined && signal !== null) {
        options.signal = signal;
        // 检查 signal 是否已经被中断
        if (signal.aborted) {
            throw new DOMException('The operation was aborted.', 'AbortError');
        }
    }
}

🎯 通用处理模式

模式一:单一请求中断

场景:用户快速操作,需要中断之前的请求

const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);

const fetchData = async () => {
    // 1. 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 2. 创建新的请求标识
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    abortController.value = controller;
    
    try {
        // 3. 检查是否仍然是最新请求
        if (requestId !== currentRequestId.value) return;
        
        // 4. 发送请求
        const result = await apiCall(controller.signal);
        
        // 5. 再次检查
        if (requestId !== currentRequestId.value) return;
        
        // 6. 处理结果
        processResult(result);
        
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
    }
};

模式二:多请求互斥中断

场景:两个不同的请求,一个触发时中断另一个

const requestAController = ref<AbortController | null>(null);
const requestBController = ref<AbortController | null>(null);

const fetchA = async () => {
    // 中断 B
    if (requestBController.value) {
        requestBController.value.abort();
        requestBController.value = null;
    }
    
    // 开始 A
    const controller = new AbortController();
    requestAController.value = controller;
    // ... 发送请求
};

const fetchB = async () => {
    // 中断 A
    if (requestAController.value) {
        requestAController.value.abort();
        requestAController.value = null;
    }
    
    // 开始 B
    const controller = new AbortController();
    requestBController.value = controller;
    // ... 发送请求
};

模式三:状态检查 + RequestId 双重验证

场景:请求结果需要匹配当前状态(如 autoSelect 状态)

const fetchData = async () => {
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    
    try {
        const result = await apiCall(controller.signal);
        
        // 1. 检查 requestId(防止竞态条件)
        if (requestId !== currentRequestId.value) return;
        
        // 2. 检查状态(确保结果匹配当前状态)
        if (!isValidState()) return;
        
        // 3. 处理结果
        updateData(result);
        
    } catch (error) {
        // 处理错误...
    }
};

🔍 常见场景及处理方法

场景 1:搜索框输入

问题:用户快速输入,需要中断之前的搜索请求

解决方法

const searchController = ref<AbortController | null>(null);
const searchRequestId = ref<number>(0);

const handleSearch = debounce(async (keyword: string) => {
    // 中断之前的搜索
    if (searchController.value) {
        searchController.value.abort();
    }
    
    const requestId = ++searchRequestId.value;
    const controller = new AbortController();
    searchController.value = controller;
    
    try {
        if (requestId !== searchRequestId.value) return;
        
        const results = await searchApi(keyword, controller.signal);
        
        if (requestId !== searchRequestId.value) return;
        
        updateSearchResults(results);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    }
}, 300);

场景 2:标签页切换

问题:切换标签页时,需要中断当前标签页的请求

解决方法

const tabControllers = ref<Map<string, AbortController>>(new Map());

const fetchTabData = async (tabId: string) => {
    // 中断当前标签页的请求
    const currentController = tabControllers.value.get(tabId);
    if (currentController) {
        currentController.abort();
    }
    
    // 创建新的请求
    const controller = new AbortController();
    tabControllers.value.set(tabId, controller);
    
    try {
        const data = await fetchTabDataApi(tabId, controller.signal);
        updateTabData(tabId, data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    }
};

场景 3:表单提交

问题:用户快速点击提交按钮,需要防止重复提交

解决方法

const submitController = ref<AbortController | null>(null);
const isSubmitting = ref<boolean>(false);

const handleSubmit = async () => {
    if (isSubmitting.value) {
        // 中断之前的提交
        if (submitController.value) {
            submitController.value.abort();
        }
    }
    
    isSubmitting.value = true;
    const controller = new AbortController();
    submitController.value = controller;
    
    try {
        await submitForm(formData, controller.signal);
        showSuccess();
    } catch (error) {
        if (error.name !== 'AbortError') {
            showError(error);
        }
    } finally {
        isSubmitting.value = false;
        submitController.value = null;
    }
};

场景 4:下拉刷新

问题:用户快速下拉刷新,需要中断之前的刷新请求

解决方法

const refreshController = ref<AbortController | null>(null);

const handleRefresh = async () => {
    // 中断之前的刷新
    if (refreshController.value) {
        refreshController.value.abort();
    }
    
    const controller = new AbortController();
    refreshController.value = controller;
    
    try {
        const data = await refreshData(controller.signal);
        updateData(data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        refreshController.value = null;
    }
};

✅ 最佳实践

1. 必须使用 RequestId

原因:防止竞态条件,确保只有最新请求的结果被使用

// ✅ 正确
const requestId = ++currentRequestId.value;
// ... 请求完成后检查
if (requestId !== currentRequestId.value) return;

// ❌ 错误:没有 requestId,无法防止竞态条件
const controller = new AbortController();

2. 多次检查 RequestId

原因:异步操作中,状态可能随时改变

// ✅ 正确:在关键步骤前都检查
const result = await apiCall();
if (requestId !== currentRequestId.value) return;

processData(result);
if (requestId !== currentRequestId.value) return;

updateUI(result);

3. 状态检查 + RequestId 双重验证

原因:确保结果不仅是最新的,还要匹配当前状态

// ✅ 正确
if (requestId !== currentRequestId.value) return;
if (!isValidState()) return; // 检查状态是否匹配

// ❌ 错误:只检查 requestId,不检查状态
if (requestId !== currentRequestId.value) return;
// 直接使用结果,可能状态已改变

4. 正确处理 AbortError

原因:AbortError 是正常的取消操作,不应该显示错误

// ✅ 正确
catch (error) {
    if (error.name !== 'AbortError') {
        handleError(error); // 只处理真正的错误
    }
}

// ❌ 错误:所有错误都处理,包括 AbortError
catch (error) {
    handleError(error); // 会显示"请求被中断"的错误提示
}

5. 在 finally 中清理 AbortController

原因:确保只有当前请求完成时才清理,避免影响新请求

// ✅ 正确
finally {
    if (requestId === currentRequestId.value) {
        abortController.value = null; // 只有当前请求完成时才清理
    }
}

// ❌ 错误:总是清理,可能影响新请求
finally {
    abortController.value = null; // 可能清理了新请求的 controller
}

6. 使用唯一的 key 创建新的 useApiFetch 实例

原因:避免 useFetch 缓存导致的问题

// ✅ 正确
const uniqueKey = `apiCall-${Date.now()}-${Math.random()}`;
const { data, execute } = useApiFetch(url, {
    signal: signal,
    key: uniqueKey, // 确保每次创建新实例
});

// ❌ 错误:使用相同的 key,可能导致缓存问题
const { data, execute } = useApiFetch(url, {
    signal: signal,
    // 没有 key,可能使用缓存的实例
});

📝 代码模板

完整模板

// 1. 定义状态
const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);
const isLoading = ref<boolean>(false);

// 2. 请求函数
const fetchData = async (params: any) => {
    // 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 创建新的请求标识
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    abortController.value = controller;
    
    isLoading.value = true;
    
    try {
        // 检查 1:请求开始前
        if (requestId !== currentRequestId.value) {
            isLoading.value = false;
            return;
        }
        
        const signal = controller.signal;
        if (!signal) {
            isLoading.value = false;
            return;
        }
        
        // 发送请求
        const result = await store.fetchData(params, signal);
        
        // 检查 2:请求完成后
        if (requestId !== currentRequestId.value) {
            return;
        }
        
        // 检查 3:状态验证(如果需要)
        if (!isValidState()) {
            return;
        }
        
        // 检查 4:更新数据前
        if (requestId !== currentRequestId.value) {
            return;
        }
        
        // 处理结果
        updateData(result);
        
    } catch (error: any) {
        // 处理错误(忽略 AbortError)
        if (error instanceof Error && error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        // 清理(只有当前请求完成时)
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
        isLoading.value = false;
    }
};

// 3. 中断函数(在状态改变时调用)
const interruptRequest = () => {
    if (abortController.value) {
        abortController.value.abort();
        abortController.value = null;
        currentRequestId.value++; // 更新 requestId,确保旧请求结果被忽略
    }
};

🚨 注意事项

  1. 不要忘记传递 signal:确保 signal 正确传递到 API 调用
  2. 不要忘记检查 requestId:防止竞态条件
  3. 不要忘记检查状态:确保结果匹配当前状态
  4. 不要忘记处理 AbortError:避免显示"请求被中断"的错误
  5. 不要忘记清理 AbortController:避免内存泄漏
  6. 使用唯一的 key:避免 useFetch 缓存问题

📚 相关文件

  • pages/wallet/components/redemption/WalletSelectionSection.vue - target-prizes 中断实现
  • pages/wallet/components/RedeemWithPrizesModal.vue - eligibleForPrizeExchange 中断实现
  • stores/wallet.ts - API 调用中的 signal 传递
  • composables/useApiFetch.ts - signal 的动态设置

还在用‘Z’字图案密码?这个网站能帮你找到更复杂个性化的手机图案密码

作者 Narrastory
2026年1月19日 16:31

手机图案密码全库检索Web应用

2026.01 | ming


AI Generated Art

图案密码,是我们日常生活中非常熟悉的一种验证方式。无论是手机锁屏、应用加密,还是隐私相册、银行类软件的二次验证,它都以直观、易操作的特点被广泛使用。虽然从安全性角度来说,它可能不如复杂字符密码,但图案密码在记忆门槛和操作趣味性上,有着非常独特的优势。

最近在给新手机设置图案密码的时候,我想了很久要用什么图案当密码,怎样的图案才能既美观别致,又具备足够的迷惑性,让他人难以轻易看透?于是就有了研究图案密码的想法。

通常所说的图案密码,指的是在一个 3×3 的点阵上,依次连接 4 到 9 个不重复的点所形成的路径。现在大部分软件的图案密码至少需要使用到4个点,并且每个点不能重复连接。那么我们就可以把这9个点看成数字小键盘,如下图所示:

[123456789]\begin{bmatrix} 1&2 &3 \\ 4&5 &6 \\ 7&8 &9 \end{bmatrix}

这样一来,每一个图案密码都能对应一串由 1~9 中不重复数字所组成的序列。比如经典的“Z”字形图案,对应的密码序列就是 [1, 2, 3, 5, 7, 8, 9]。因此,图案密码问题就转化为了数字序列的生成与筛选问题。

既然每个点只能使用一次,且密码长度范围为 4~9,那么总的图案数量就是从 9 个点中选取 nn 个点(nn 从 4 到 9),并对它们进行全排列的总和。用数学公式表达就是:

n=49A9n=985824\sum_{n=4}^{9} A_{9}^{n} = 985824

也就是说,一共有 985,824 种可能的图案。这个数字听起来很大,但对于现代计算机而言,完全在可遍历处理的范围内——我们完全可以通过编写筛选算法,快速找出符合特定样式或规则的图案。

如果你觉得 98 万仍然是个令人望而却步的数字,甚至担心遍历会耗时过长,那或许低估了现代计算机的运算能力。当然,我们还可以通过“去冗余”进一步简化问题。仔细观察下面这组图案:

c3.jpg

你会发现,这 8 个图案其实源自同一个“基础图形”,只不过分别经过了 90°、180°、270° 旋转,以及水平翻转、垂直翻转、沿主对角线翻转、沿副对角线翻转等对称变换。在视觉和结构上,它们属于同一类密码模式。因此,如果我们只保留每组对称图案中的“基础版本”,就可以将图案总数量减少至原来的 1/8:

9858248=123228\frac{985824}{8} = 123228

这样一来,我们只需在 12.3 万 个有效图案中进行筛选和检索,效率大大提升,也更便于用户聚焦于真正独特的密码设计。

有了想法,就开始着手设计筛选程序了,我一开始打算使用React框架开发一个Web网页的,但是由于有几年没碰React了,我的React还是5年前学的,当初学习的版本与如今的最新语法差异巨大,一时间感到无从下手。并且我也不愿再花时间重新学习它,于是就干脆决定转向 Vue3,好在有过去的前后端开发基础,学习起来并不算吃力。

更重要的是,如今有了 AI 的加持。回想五年前刚入门前后端时,无论是学习新技术还是动手开发,整个过程都相当漫长,每个新知识都要依赖文档、教程或是反复试错,过程漫长且充满不确定性。而现在,AI 极大地加速了这一进程——遇到问题随时提问,AI 给出的解答往往比许多教学视频更直接、更有针对性。在开发过程中,它的辅助更是让效率大幅提升:想要什么组件,什么布局,什么样式,直接向AI描述清楚即可。就拿这个图案密码筛选网站来说,从动手编码到基本完成,前后只用了两天时间。我的大部分精力都放在了系统架构的设计、用户交互的逻辑、筛选规则的制定、图案的绘制方式,以及如何将各个模块有机整合……这些真正需要思考的问题上。

在 AI 时代,我认为程序员的核心价值,越来越体现在对系统架构的把握与全局设计的视野上。很多人为了省事,倾向于让 AI 智能体直接生成整个应用或大型模块;在我看来,这并不是 AI 的最佳使用方式。没有扎实的技术基础,仅凭 AI 生成的项目无异于空中楼阁,难以稳固、更难以迭代。但当你对一个项目的整体流程有了清晰概念和想法,AI 就能成为你得力的助手,是你在主导AI,而不是AI在领导你。这正是“懂技术”和“不懂技术”的人在使用 AI 时的关键差异,你的技术基础,决定着你的AI生成能力的上界。工具越是强大,人的思维和视野就越显珍贵。

回到正题,你可以直接访问这个网址:narrastory.rth1.xyz/ 来在线体验这个工具。(推荐使用125%页面缩放)

c1.jpg

接下来,我将详细介绍这个工具中提供的几个筛选条件,帮助你更精准地找到心仪的图案密码。

1. 节点数量

节点数量指的是图案密码中连接的点数,范围在 4 到 9 个 之间。点数越多,图案越复杂,可能的组合也越多。

c4.jpg

2. 模式选择

在手机图案密码的设置中,通常有两种连接模式:

  • 限制模式:也是大部分手机的默认模式。当你想连接的两个点之间存在未被使用过的中间点时,系统会自动将该中间点纳入路径。 例如:连接 1→3 会自动变成 1→2→3;连接 1→9 会自动经过 5,变成 1→5→9。 这种模式虽然方便,但也限制了一些特殊“跳点”图案的生成。
  • 非限制模式:关闭限制后,你可以自由连接任意两个点,中间即使有未使用的点也不会被自动加入。 例如:可以直接连接 1→3,系统不会添加点 2

c5.jpg

3. 跳点设置

“跳点”是指在连接路径中,跳过某个中间点直接连接更远的点。例如在序列 [2,1,3] 中,从 1 连接到 3 就跳过了 2;在 [3,2,1,9] 中,从 19 跳过了 5

跳点数量反映了一个图案的“非常规”程度。一般来说,跳点越多的图案看起来越不规则,迷惑性也更强,有助于提高密码的防窥视能力。

c7.jpg

4. 线长排序

“线长”是指构成整个图案的线段总长度。我们可以依次计算相邻两点间的欧几里得距离并累加得到总长。例如下图所示:

c6.jpg

上面这个图案的线长就可以这么计算

线长=5+2+1+26.65线长 = \sqrt{5} + \sqrt{2} + 1 + 2 \approx 6.65

你可以选择“按照总线长降序排列”,让最长的图案排在前列。

5. 线条样式设置

图案密码中的线段可分为三种基本类型:

  1. 直线:水平或垂直方向(如 1→24→7
  2. 45° 斜线:斜率为 ±1(如 1→53→7
  3. 1:2斜线:斜率为 ±2 或 ±1/2(如 1→62→7

c2.jpg

你可以分别设置筛选每种线型的数量,从而控制图案的“几何风格”。比如,只包含 45° 斜线的图案会呈现出整齐的对角线美感;而混合多种线型则会让图案更富有变化。

这个工具不仅是一个密码生成器,也是一次关于排列组合、几何连接与交互设计的轻量实践。如果你对实现细节感兴趣,或希望在本地方便地修改和尝试,项目的完整代码已经开源在:

GitHub:github.com/narrastory/…

需要提醒的是,图案密码在安全性上存在固有弱点。图案密码虽然有趣易记,但如果让他人看到你的图案,即使别人未直接看清连接顺序,但仅凭图案形状,也可能在短时间内推测出可能的连接路径。因此,它更适合用于对安全性要求不高的场景,或作为辅助记忆的趣味选择。

感谢阅读,祝你探索愉快! 🔐✨

Vue3 toRef/toRefs 完全指南:作用、场景及父子组件通信实战

作者 boooooooom
2026年1月18日 21:16

在Vue3组合式API开发中,reactive用于创建复杂引用类型的响应式数据,但其存在一个核心痛点——直接解构会丢失响应式。而toReftoRefs正是为解决这一问题而生的“响应式保留工具”,尤其在将reactive数据拆分传递给子组件时,是保障响应式连贯性的关键。本文将从核心作用、区别对比、典型场景三个维度,结合父子组件通信实例,彻底讲透toRef/toRefs的用法与价值。

此前我们已了解,reactive创建的响应式对象,直接解构会破坏响应式(本质是解构后得到的是普通属性值,脱离了Proxy拦截范围)。而toRef/toRefs能在拆分数据的同时,保留与原reactive对象的响应式关联,这一特性在组件通信、状态拆分场景中至关重要。

一、核心作用:保留响应式的“拆分工具”

1. toRef 的核心作用

toRef用于为reactive对象的单个属性创建一个Ref对象,核心特性的是:

  • 响应式关联保留:创建的Ref对象与原reactive对象属性“双向绑定”——修改Ref对象的.value,会同步更新原reactive对象;反之,原reactive对象属性变化,也会同步到Ref对象。
  • 非拷贝而是引用:toRef不会拷贝属性值,仅建立引用关系,避免数据冗余,尤其适合大型对象场景。
  • 支持可选属性:即使原reactive对象中该属性不存在,toRef也能创建对应的Ref对象(值为undefined),不会报错,适配动态属性场景。

语法:toRef(reactiveObj, propertyKey),第一个参数为reactive创建的响应式对象,第二个参数为属性名。

2. toRefs 的核心作用

toRefs是toRef的“批量版本”,用于将整个reactive对象拆分为多个Ref对象组成的普通对象,核心特性:

  • 批量转换:遍历reactive对象的所有可枚举属性,为每个属性创建对应的Ref对象,最终返回一个普通对象(非响应式),其属性与原reactive对象属性一一对应。
  • 响应式联动:每个拆分后的Ref对象都与原reactive对象属性保持双向关联,修改任一方向都会同步更新。
  • 解构安全:将reactive对象转为Ref对象集合后,可安全解构,解构后的属性仍保持响应式,解决了reactive直接解构丢失响应式的痛点。

语法:toRefs(reactiveObj),仅接收一个reactive创建的响应式对象参数。

3. toRef 与 toRefs 的核心区别

维度 toRef toRefs
转换范围 单个属性(精准定位) 所有可枚举属性(批量转换)
返回值类型 单个Ref对象 普通对象(属性均为Ref对象)
适用场景 仅需拆分reactive对象的部分属性 需拆分reactive对象的全部属性,或需解构使用
性能开销 极低(仅处理单个属性) 略高于toRef(遍历对象属性),但可忽略

二、典型使用场景:从基础到组件通信

1. 基础场景:解决reactive解构丢失响应式问题

这是toRef/toRefs最基础的用法,直接解构reactive对象会导致响应式失效,而通过toRef/toRefs转换后可安全解构。

import { reactive, toRef, toRefs } from 'vue';

const form = reactive({
  username: '',
  password: '',
  remember: false
});

// 错误示例:直接解构丢失响应式
const { username, password } = form;
username = 'admin'; // 仅修改普通变量,原form无变化

// 正确示例1:toRef 转换单个属性
const usernameRef = toRef(form, 'username');
usernameRef.value = 'admin'; // 原form.username同步更新为'admin'

// 正确示例2:toRefs 批量转换后解构
const { password: passwordRef, remember: rememberRef } = toRefs(form);
passwordRef.value = '123456'; // 原form.password同步更新
rememberRef.value = true; // 原form.remember同步更新

2. 核心场景:reactive数据拆分传递给子组件

在父子组件通信中,若父组件使用reactive管理聚合状态(如表单、用户信息),需将部分/全部属性传递给子组件时,toRef/toRefs能保障子组件修改后同步反馈到父组件,且不破坏响应式链路。这是日常开发中最常用的场景,分为“部分属性传递”和“全部属性传递”两种情况。

场景1:传递reactive对象的部分属性给子组件

当子组件仅需父组件reactive对象的个别属性时,用toRef精准转换对应属性,传递给子组件后,子组件修改该Ref对象,父组件原reactive对象会同步更新。

// 父组件 Parent.vue
<script setup>
import { reactive, toRef } from 'vue';
import Child from './Child.vue';

// 父组件用reactive管理用户信息
const user = reactive({
  name: '张三',
  age: 20,
  info: { height: 180 }
});

// 仅将name属性传递给子组件,用toRef保留响应式
const nameRef = toRef(user, 'name');
</script>

<template>
  <div>父组件:姓名 {{ user.name }}</div>
  <Child :name="nameRef" />
</template>

// 子组件 Child.vue
<script setup>
import { defineProps, Ref } from 'vue';

// 子组件接收Ref类型的props
const props = defineProps({
  name: {
    type: Ref,
    required: true
  }
});

// 子组件修改props(实际开发中建议通过emit触发父组件修改,此处为演示响应式关联)
const updateName = () => {
  props.name.value = '李四'; // 父组件user.name同步更新为'李四'
};
</script>

<template>
  <div>子组件:姓名 {{ name.value }}</div>
  <button @click="updateName">修改姓名</button>
</template>

注意:Vue官方建议“单向数据流”——子组件不直接修改props,应通过emit通知父组件修改。上述示例仅演示响应式关联,实际开发中可让子组件触发emit,父组件修改reactive对象,子组件通过props自动同步。

场景2:传递reactive对象的全部属性给子组件

当子组件需要父组件reactive对象的全部属性时,用toRefs批量转换后,通过展开运算符传递给子组件,子组件可直接解构使用,且保持响应式。

// 父组件 Parent.vue
<script setup>
import { reactive, toRefs } from 'vue';
import Child from './Child.vue';

// 父组件reactive聚合表单状态
const form = reactive({
  username: '',
  password: '',
  remember: false
});

// 批量转换为Ref对象集合
const formRefs = toRefs(form);
</script>

<template>
  <div>父组件:用户名 {{ form.username }}</div>
  <!-- 展开传递所有属性,子组件可按需接收 -->
  <Child v-bind="formRefs" />
</template>

// 子组件 Child.vue
<script setup>
import { defineProps, Ref } from 'vue';

// 子组件按需接收props
const props = defineProps({
  username: { type: Ref, required: true },
  password: { type: Ref, required: true },
  remember: { type: Ref, required: true }
});

// 子组件触发父组件修改(遵循单向数据流)
const handleInput = (key, value) => {
  props[key].value = value; // 父组件form同步更新
};
</script>

<template>
  <input 
    v-model="username.value" 
    placeholder="请输入用户名"
    @input="handleInput('username', $event.target.value)"
  />
  <input 
    v-model="password.value" 
    type="password" 
    placeholder="请输入密码"
    @input="handleInput('password', $event.target.value)"
  />
  <input 
    v-model="remember.value" 
    type="checkbox"
  /> 记住我
</template>

该场景的优势的是:父组件无需逐个传递属性,子组件可按需接收,且所有属性的响应式链路完整,父组件状态与子组件同步一致。

3. 进阶场景:组合式API中拆分状态逻辑

在组合式API中,常将复杂状态逻辑抽离为独立函数(Composable),函数返回reactive对象时,可通过toRefs拆分后返回,便于组件内解构使用,同时保留响应式。

// composables/useUser.js(抽离用户状态逻辑)
import { reactive, toRefs } from 'vue';

export function useUser() {
  const user = reactive({
    name: '张三',
    age: 20,
    updateName: (newName) => {
      user.name = newName;
    }
  });

  // 拆分后返回,组件可解构使用
  return { ...toRefs(user), updateName: user.updateName };
}

// 组件中使用
<script setup>
import { useUser } from '@/composables/useUser';

// 解构后仍保持响应式
const { name, age, updateName } = useUser();
updateName('李四'); // name.value同步更新为'李四'
</script>

三、避坑要点:这些细节千万别忽略

  • 仅适用于reactive对象:toRef/toRefs的核心作用是处理reactive对象的属性拆分,若用于普通对象或ref对象,虽不会报错,但无法实现响应式关联(普通对象无Proxy拦截,ref对象本身已可通过.value操作)。
  • 不触发新的响应式依赖:toRef/toRefs创建的Ref对象与原reactive对象共享同一响应式依赖,修改时不会新增依赖,仅触发原有依赖更新,性能更优。
  • 嵌套属性的处理:若reactive对象包含嵌套对象,toRef/toRefs仅对顶层属性创建Ref对象,嵌套属性仍为普通对象(需通过.value访问后再操作)。若需嵌套属性也转为Ref,可结合toRef递归处理,或直接使用ref嵌套。
  • 与ref的区别:ref是“创建新的响应式数据”,而toRef是“关联已有reactive对象的属性”,两者本质不同——ref的数据独立,toRef的数据与原对象联动。

四、总结

toRef与toRefs作为Vue3组合式API的“响应式辅助工具”,核心价值在于拆分reactive对象时保留响应式关联,解决了直接解构导致的响应式失效问题。其中,toRef适用于精准拆分单个属性,toRefs适用于批量拆分全部属性,两者在父子组件通信、状态逻辑抽离等场景中不可或缺。

尤其在父子组件传递reactive数据时,toRef/toRefs能保障数据链路的完整性,既满足子组件对数据的使用需求,又遵循Vue的单向数据流原则,是实现组件间状态协同的高效方案。掌握两者的用法与区别,能让你的响应式开发更灵活、更健壮。

❌
❌