# ONE - AI永久记忆系统 这是一个基于大语言模型的AI助手系统,具有记忆管理、对话能力和API调用功能。 ## 主要特性 - 智能对话:基于大语言模型的自然对话能力 - 记忆系统:分层的记忆管理,包括基础记忆、记忆快照和元快照 - 动态分类:自动对记忆进行分类和管理 - 上下文感知:根据对话上下文检索相关记忆 - API调用:支持通过对话调用外部API,实现更强大的功能扩展 ## 系统架构 系统主要包含以下模块: - `core/`: 核心功能模块 - `memory/`: 记忆系统实现 - `processor/`: 记忆处理器 - `retrieval/`: 记忆检索 - `chat/`: 对话管理 - `api/`: API接口 - `services/`: 外部服务集成 - `utils/`: 工具函数 - `config/`: 配置文件 - `frontend/`: 前端界面实现 ### 记忆系统 记忆系统采用三层架构: 1. 基础记忆(BaseMemory) - 存储完整的对话内容和上下文 - 包含时间戳、重要性等元数据 - 记录API调用相关信息 2. 记忆快照(MemorySnapshot) - 提取记忆的关键信息 - 包含对原始记忆的引用 - 按类别组织 - 保存API调用结果和分析 3. 元快照(MetaSnapshot) - 对相似快照进行分类和总结 - 提供更高层次的记忆组织 - 总结API使用模式和效果 ## 配置说明 系统配置位于`config/`目录: - `default_memory_config.json`: 记忆系统配置 - `storage`: 存储相关配置 - `snapshot`: 快照处理配置 - `chat`: 对话相关配置 - `api`: API调用相关配置 ## API接口 ### 对话接口 ```http POST /chat Content-Type: application/json { "query": "用户输入", "context": { "enable_api_call": true, // 是否启用API调用 "api_docs": "API文档内容" // API接口文档 } } ``` ### 记忆管理 ```http POST /clear_history # 清空对话历史 POST /cleanup_memories # 清理旧记忆 GET /memory_stats # 获取记忆统计 ``` ## 开发指南 1. 安装依赖 ```bash pip install -r requirements.txt 修改.env.example文件为.env文件 ``` 2. 运行 ```bash python run.py cd .\frontend\ npm install npm run dev ``` ## API调用功能 系统支持通过对话方式调用外部API,主要特点: 1. 动态API调用 - 支持在对话中启用/禁用API调用 - 可以动态提供API文档 - 自动分析API调用需求 2. 智能分析 - 自动分析用户需求 - 匹配合适的API - 生成调用计划 3. 安全控制 - API调用开关 - 文档验证 - 调用限制 4. 结果处理 - 自动处理API响应 - 整合到对话流程 - 记录调用历史 5. 使用方法 - 在对话界面启用API调用开关 - 提供API文档(支持OpenAPI/Swagger格式) - 正常进行对话,系统会自动判断是否需要调用API ## 注意事项 - 定期清理旧记忆以优化存储空间 - 合理配置记忆重要性阈值 - 监控记忆统计信息 - API调用相关: - 确保API文档格式正确 - 注意API调用频率限制 - 定期检查API可用性 ## UI参考  ## 界面使用说明 ### PC 端操作 1. **基本对话** - 在输入框中输入消息 - 按 Enter 发送消息 - 按 Shift + Enter 换行 - 点击"清空对话"重置对话 2. **API 设置** - 点击右上角"API 设置"按钮 - 开启/关闭 API 调用功能 - 输入 API 文档内容 - 点击关闭按钮保存设置 3. **思考过程** - 右侧面板实时显示 AI 思考步骤 - 自动滚动显示最新步骤 - 清晰的步骤分类和图标 ### 移动端操作 1. **基本对话** - 在底部输入框输入消息 - 点击发送按钮(📤)发送消息 - 点击清空按钮(🗑️)重置对话 2. **API 设置** - 点击右上角设置图标(⚙️) - 在弹出面板中配置 API 设置 - 点击关闭按钮保存并返回 3. **思考过程** - 点击右下角思考按钮(🧠)查看思考过程 - 向上滑动查看历史步骤 - 点击顶部关闭按钮返回对话 ### 界面元素说明 1. **消息显示** - 用户消息:右侧蓝色气泡 - 系统回复:左侧深色气泡 - 时间戳:消息底部显示 2. **思考过程** - 步骤编号:顺序显示 - 类型图标:直观区分步骤类型 - 详细描述:展示具体思考内容 - 执行结果:显示操作结果 3. **API 设置面板** - 开关控制:一键开启/关闭 - 文档输入:支持多行文本 - 状态提示:显示警告和提示信息 ### 移动端使用建议 1. **操作建议** - 使用现代移动浏览器访问 - 保持良好的网络连接 - 注意设备电量消耗 2. **性能优化** - 长时间对话建议定期清空 - 避免输入过长的消息 - 合理使用 API 调用功能
举例因为乱序的代码是比较多的,所以我们主要来说一下它的核心逻辑。对于我们当前的例子而言,首先,我们经过第一步(自前向后比对)、第二步(子后向前比对)之后,结果如下:对于第三步(挂载多的新节点)、第四步(卸载多的旧节点)而言,我们的新、旧节点数量是相同的,所以不需要做任何处理。这样,我们就来到了第五步 —— 乱序。乱序处理对于乱序而言,它主要处理三种情况:删除旧节点 f挂载新节点 g对于节点 b-d 进行移动删除旧节点删除的逻辑是:如果旧节点的 key 在新节点列表中找不到了,那么我们就认为这个旧节点应该被删除,比如我们这里的节点 old-f,它的 key 是 6,在新节点列表中找不到 key 为 6 的元素,所以我们就将它删除。但是,如果对于每个旧节点,我们都通过这个旧节点的 key 去新节点列表中查找,有时候是不必要的。所以,vue 在这里做了一个优化,它在记录 2 个变量:已经修复的新节点数量 patched 和 新节点待修复的数量 toBePatched。在每次修复完一个新节点后,都会 patched++,然后在执行我们上面的查找行为之前,会判断一下:patched >= toBePatched?,如果是 true,就代表着当前所有的新节点都已经处理完了,那么对于剩下的旧节点,我们不用再去查找,直接删除即可。挂载新节点挂载的逻辑就比较简单了:如果拿新节点的 key,没有找到对应的旧节点,比如我们这里的节点 new-g,它的 key 是 7,在旧节点列表中找不到 key 为 7 的元素,所以我们就将它挂载。移动节点现在,我们有 3 个节点需要移动:b、c、d。那么,如何移动才能最高效呢?我们通过肉眼可以知道:我们只需要将 b 移动到末尾就可以了!但是,vue 该如何知道呢?这里,我们就需要介绍一个 最长递增子序列 的概念。最长递增子序列假如我们有:旧数组:[1, 2, 3, 4, 5, 6]新数组:[1, 3, 2, 4, 6, 5]对于新数组而言,我们如果想找到 递增子序列 的话,可以找到很多:1, 3, 61, 2, 4, 5...确认这个递增子序列的目的,就是为了在由旧数组变成新数组的时候,递增子序列里的这些元素可以不用移动。比如,对于 1, 3, 6 而言,我们可以不需要移动它们,只需要移动剩下的 2, 4, 5 到对应的位置即可,总共需要 3 次移动。对于 1, 2, 4, 5 而言,我们可以不需要移动它们,只需要移动剩下的 3, 6 到对应的位置即可,总共需要 2 次移动。显而易见的是,当我们的递增子序列越长,我们所需要的移动次数就越少。所以,我们求解最长递增子序列的目的就是:减少移动次数,进行更高效地移动。对于我们目前的例子对于我们现在而言,我们的数组里的元素是:新节点在旧节点列表里的索引。比如,我们的 new-c,它在旧节点中的索引是 2,同理,new-d 是 3,new-b 是 1。所以,我们可以得到一个数组:[2, 3, 1]。对于这个数组而言,最长递增子序列为:[2, 3],也就是 c 和 d 不需要移动,只需要移动 b 即可。这样一来,我们就通过 最长递增子序列 完成了最高效的移动操作。总结至此,我们完成了乱序的处理。其中,涉及到了一个新的概念,最长递增子序列,它其实只是用来处理移动的场景,让我们的移动能够高效的进行。
两组元素个数相同的时候首先,我们先来看下元素个数相同的时候,比如:旧节点,ul 元素下是一组 li,总共 3 个 li:<ul> <li>a</li> <li>b</li> <li>c</li> </ul>新节点,ul 元素下也是一组 li,总共也是 3 个 li:<ul> <li>a</li> <li>b</li> <li>d</li> // 第三个 li 元素发生了变化!👾 </ul>这个时候,我们只需要遍历两组子节点,然后依次更新每一个节点就可以了,代码如下:const oldChildren = ul1.children; const newChildren = ul2.children; for (let i = 0; i < oldChildren.length; i++) { // 调用 patch 函数依次更新子节点 patch(oldChildren[i], newChildren[i]); }两组元素个数不同的时候但在实际情况中,我们新的一组元素常常和旧的一组元素个数不一样。这个时候,就会有 2 种情况:新的一组元素数量 > 旧的一组元素数量新的一组元素数量 < 旧的一组元素数量这个时候,我们可以首先获取两组子节点公共的元素数量的长度,然后如果新的一组元素多,则挂载剩余的新元素;新的一组元素少,则卸载旧元素即可。代码如下:const oldChildren = ul1.children; const newChildren = ul2.children; const oldChildrenLength = oldChildren.length; const newChildrenLength = newChildren.length; // 获取公共的长度 const commonLength = Math.min(oldChildrenLength, newChildrenLength); for (let i = 0; i < commonLength; i++) { patch(oldChildren[i], newChildren[i]); } // 新的一组元素多,则挂载剩余的新元素 if (newChildrenLength > oldChildrenLength) { for (let i = commonLength; i < newChildrenLength; i++) { patch(null, newChildren[i]); } } // 新的一组元素少,则卸载旧元素即可 else if (newChildrenLength < oldChildrenLength) { for (let i = commonLength; i < oldChildrenLength; i++) { unmount(oldChildren[i]); } }两组元素的顺序不同还有一种更加复杂,但是也是很常见的情况,就是新的一组元素的顺序和旧的一组元素的顺序是不同的,比如:旧节点:[ { type: "p", children: "我是p1" }, { type: "p", children: "我是p2" }, { type: "p", children: "我是p3" }, ];新节点:[ { type: "p", children: "我是p2" }, { type: "p", children: "我是p3" }, { type: "p", children: "我是p1" }, ];这个时候,最高效的更新节点的方式是:将 我是p1 移动到新的一组节点的末尾即可。但是,这对于我们肉眼来说,是很简单的。但程序怎么知道谁是 p1 呢?因为我们现在只用了 type 这个属性来区分不同的节点,这个属性的值可能是 p、div、li...所以,我们需要引入一个新的属性来区分相同的 type 为 p 的节点,这个新属性就是 key。旧节点:[ { type: "p", children: "我是p1", key: 1 }, { type: "p", children: "我是p2", key: 2 }, { type: "p", children: "我是p3", key: 3 }, ];新节点:[ { type: "p", children: "我是p2", key: 2 }, { type: "p", children: "我是p3", key: 3 }, { type: "p", children: "我是p1", key: 1 }, ];这个时候,我们就很清晰的知道:对于 p1 来说,它从旧节点的第一位,移动到了新节点的第三位。总结那么今天,我们一共探讨了diff算法 需要处理的 3 种不同的情况。但现在,我们只是从理论的角度进行了分析,但 vue3 中,它具体是如何实现这个 diff算法 的呢?比如,我们这里最后说的 移动节点,我们可以把 p1 移动到最后,也可以把 p2、p3 往前移动,那么 vue3 是如何求得最优的移动方案的呢?这个,我们就留到下一回再说。
最近在个人开发的小程序中,想要改版设计一个好看点的滑动选择器,因为自带的滑动选择器实在是太太太丑了。自带的小程序slider长这样:经过各种APP探索,最终小红书的【身高体重选择器】映入我的眼帘,它长这样:依葫芦画瓢有了葫芦好画瓢,接下来就看下怎么在小程序中实现同样的效果吧。使用scroll-view组件很显然,这里滚动需要用到scroll-view组件,监听滚动的距离实时计算出对应值。布局实现我们先仔细观察下结构可以看到,该滑动器主要由两部分组成:浮标及指示尺,其中浮标是需要固定位置显示,所以在写布局的时候,我们不能将浮标标签写在scroll-view里面,需要与scroll-view同级通过定位方式显示。另外,由于起始值跟最大值需要可以滚动到浮标位置,所以scroll-view里面的起始跟结尾需要有标签宽度进行占位。指示尺的实现就比较简单,使用盒子+borderRight实现即可。布局代码实现说明:以下代码使用uniapp vue3 setup 写法实现 <view class="relative"> <!-- 浮标尺 --> <view class="pointer-wrap"> <text class="triangle"></text> <text class="slide-num">{{ slideNum }}岁</text> <text class="pointer"></text> </view> <scroll-view :scroll-with-animation="true" :scroll-left="scrollLeft" scroll-x enhanced :show-scrollbar="false" :bounces="true" @scroll="onScroll" class="relative" > <view class="expand-line"> <text class="gap-space"></text> <text class="line-box" :id="'box' + idx" :class="{ higher: idx % 5 === 0 }" v-for="(_, idx) in max - min + 1" > <text class="num" v-if="idx % 5 === 0">{{ min + idx }}</text> </text> <text class="gap-space last"></text> </view> </scroll-view> </view> 其中值得一提的是,隐藏scroll-view滚动条通过配置show-scrollbar:false即可。css样式: <style lang="scss"> .pointer-wrap { position: absolute; z-index: 9; left: 166px; top: 20px; .triangle { position: absolute; top: -10px; left: -4px; width: 0; height: 0; border-top: 5px solid #efaf13; border-bottom: 5px solid white; border-left: 5px solid white; border-right: 5px solid white; } .slide-num { position: absolute; width: 40px; text-align: center; font-size: 14px; color: #efaf13; top: -30px; left: -16px; font-weight: bold; } .pointer { display: inline-block; width: 1px; height: 20px; background-color: #efaf13; } } .expand-line { position: relative; white-space: nowrap; padding-top: 20px; padding-bottom: 20px; .gap-space { display: inline-block; width: 157px; height: 12px; &.last { width: 171px; } } .line-box { box-sizing: border-box; position: relative; display: inline-block; width: 10px; height: 12px; border-right: 1px solid #ccc; &.higher { height: 20px; } .num { position: absolute; bottom: -18px; right: -7px; font-size: 12px; } } } </style> 逻辑实现以上标签布局及样式就简单带过,接下来重点解析js逻辑实现。监听滚动动画停止我们在滑动的时候,由于惯性在手指离开的时候,会继续滚动一段距离才停下,所以我们需要监听滚动停止事件。重点来了,小程序该组件当前只提供了手指离开的事件,并没有提供动画滚动结束的api,所以只能取巧实现,代码如下:function onScroll(e) { if (scrollEndTimer) { clearTimeout(scrollEndTimer) scrollEndTimer = null } scrollEndTimer = setTimeout(() => { // 滑动结束 }, 300) } 通过判断onScroll事件回调,如果300毫秒内没有回调则判断为动画滚动结束(注意:300毫秒参数来源于真机调试测试出来的值,不排除个别机型有差异)滚动到指示器中间位置,进行精确定位在滚动的时候,我们的浮标可能落在两条线中间区域,这时候就要判断下,滑动的位置是否超过一半,如果超过一半则跳去下条线的位置,否则回到上条线的位置。function onScroll(e) { const left = e.detail.scrollLeft const correctLeft = Math.floor(left / boxWidth) * boxWidth const leftMore = left % boxWidth < boxWidth / 2 ? 0 : boxWidth scrollLeft.value = correctLeft + leftMore } 获取间隔盒子实际渲染宽度上面的boxWidth就是线条之间的间隔(盒子宽度),这边是设为20rpx。注意,这里rpx单位也就意味着他会根据不同设备可能会出现不一样的实际渲染宽度(px单位),例如iPhone 14/Pro 上渲染是10px,iPhone 14 Pro Max则渲染出11px,所以我们在进行计算滑动位置时,需要获取到页面上实际渲染的宽度。实现如下:const { ctx } = getCurrentInstance() onMounted(() => { const query = uni.createSelectorQuery().in(ctx) query .select('#box0') .boundingClientRect((data) => { if (data && data.width) { boxWidth = data.width } }) .exec() }) 注意,以上代码是通过封装组件方式实现,所以在获取元素大小位置信息时,需要传入this参数,而上面的ctx是uniapp vue3 setup写法。处理边界值在滑动到最末端时,由于弹性动画效果,会出现超过最大值或低于最小值,需要处理下实时显示的值let setValueTimer = null function onScroll(e) { if (!setValueTimer) { setValueTimer = setTimeout(() => { const left = e.detail.scrollLeft const val = props.min + Math.floor(left / boxWidth) slideNum.value = val > props.max ? props.max : val slideNum.value = val < props.min ? props.min : val setValueTimer = null }, 60) } } 体验优化:添加震动反馈为了更好的体验,我们可以在滑动的时候添加震动反馈,但是这里需要注意的是,iPhone 由于很早之前就使用tapic engine震动带来非常好的统一震动体验,所以在iOS端加上震动能增强体验。但是安卓端由于机型众多,使用的震动反馈强度也不一样,反而容易带来不好的体验,所以建议通过判断系统只在iOS端加上震动反馈。let vibrateShortTimer = null const systemInfo = uni.getSystemInfoSync() const isIOS = systemInfo.platform === 'ios' function onScroll(e) { if (isIOS && !vibrateShortTimer) { vibrateShortTimer = setTimeout(() => { uni.vibrateShort({ type: 'light', }) vibrateShortTimer = null }, 300) } } 最后看下实现效果吧微信小程序搜索【识光】小程序可以查看体验,或者评论区扫码即可查看不完美的地方经过真机测试,在部分安卓手机上会出现浮标线未能对齐间隔线的细微间距,这个可能由于分辨率或其他原因影响暂时无法保证做到所有机型完美对齐。获取组件源码目前该组件已经封装成组件使用(uniapp vue3 setup),需要源码的欢迎私信我获取呀~(^_-)
前言在这一节,我们先回顾下小程序路由基础知识,然后针对小程序路由存在的问题做相关封装优化。小程序路由基础知识API小程序路由一共有5个API:switchTab:跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面reLaunch: 关闭所有页面,打开到应用内的某个页面redirectTo: 关闭当前页面,跳转到应用内的某个页面。但是不允许跳转到 tabbar 页面navigateTo: 保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面。navigateBack: 关闭当前页面,返回上一页面或多级页面。页面间传递方式页面间传递参数是通过 url 拼接参数的方式,如传递一个id=1的参数写法如下:uni.navigateTo({ url: '[目标页面地址]?id=1' }) 然后在目标页面的onLoad生命周期钩子获取到上个页面传递的参数。路由优化封装目的针对以上路由基础知识,以下的路由封装方法,主要出于以下5个目的:1、简化写法:通过上面的基础知识我们了解到,页面间跳转需要这么写:uni.navigateTo({ url: '/pages/index/index' }) 这种写法无疑过于繁琐,目标希望简化成:router.navigate('index')2、typescript类型提示既然是typescript项目,我们就需要尽可能给代码完善类型提示:1、在路由跳转时,封装的写法能够提供路由别名候选提示,并在我们写错别名时给予错误提示router.navigate('details') // 写成detalis出现错误提示 2、获取路由参数时,路由参数应该有类型提示3、优化传参:原生通过参数拼接的方式,主要有以下几个弊端问题:在写法上不便:需要我们先将参数转化后再拼接在链接后面,如果遇到较复杂点的json数据还需做进一步处理取值被约束:获取参数只能在onLoad生命周期钩子里面获取。无法跨页面使用参数:如上图。假设页面a传递参数给到b页面使用,而c页面也恰好需要用到a页面传递给b页面的某些参数,那么这时候就需要b页面跳去c页面时再做一次传递,这样不仅写法麻烦,也给后续的维护带来一定的麻烦。reLaunch和switchTab方法不支持传递参数:不知道官方设计的用意如何,不支持传参不代表实际场景不需要用到。这里举个真实场景例子,证明switchTab也是有需要支持传递参数的时候:如上图:假设我们的小程序在一进入时需要进行一个初始化设置,用户在完成某些选择之后(如选择性别、年龄等基础信息),才进入到小程序tab页面,这时候点击“点击进入”调用的是switchTab方法,我们需要将用户初始设置的参数传递到主页做进一步处理。当然,解决的方法有多种,例如我们可以调用本地存储的方法暂存,然后再取值,但是通过路由传参统一管理参数无疑是更适合的写法综合以上弊端问题的分析,这里的解决的方案也很简单:抛弃原生路由拼接参数传递方式,直接定义一个全局对象存储。4、navigateTo方法处理用户多次点击小程序调用navigateTo方法,实际上会创建一个新的webview,接着初始化新页面的生命周期里面的代码逻辑,如果遇到页面比较复杂的情况,初始化就可能比较耗时,导致用户点击跳转下一页会可能就会出现卡顿。此时用户可能多次点击触发navigateTo方法,进而有可能导致重复加载同一个页面。5、路由返回方法增加携带参数在某些场景中,我们希望页面返回时,能够携带参数给上一级页面。例如用户在A页面点击选择地址,然后跳转去一个地址列表页面,在地址列表页面用户完成选择某项地址后,携带该地址返回,A页面接收到后再更新地址。目标希望可以这么调用:router .navigate('page', { id: 1 }) .then((data) => { // 接收到上个页面返回的数据 console.log(data) }) .catch((err) => { console.log(err) }) 上手封装有了以上封装设计目的,接下来就开始正式上手封装代码。我们在src目录下新建个router文件夹,里面新建index.ts、pages.ts、types.ts三个文件。在上面的路由优化设计目标中,我们第一个希望优化的目标是希望简化路由写法成:router.navigate('index')这就需要我们存放一个路由页面集合,给每个页面路径设置别名,pages.ts内容增加:// 主包 const mainPackage = { index: '/pages/index/index', } // 分包 const subPackage = { subIndex: '/package-sub/pages/index/index', } const pages = { ...mainPackage, ...subPackage, } export default pages 在index.ts中引入,并定义页面别名类型:import pages from './pages' type PageNames = keyof typeof pages 接下来处理路由传参,先定义一个store存放所有页面路由参数:const routeStore: Record<PageNames, unknown> = {} 假设我们从首页点击进入课程详情页,路由需要传递一个id代表课程id,我们可以在types.ts新增:export interface CourseDetails { id: number } 然后在index.ts中引入:import { CourseDetails } from './types' 接着定义ObjectType泛型:type ObjectType<T> = T extends 'courseDetails' ? CourseDetails : never 路由方法:function navigate<T extends PageNames>(page: T, params: ObjectType<T>) { routeStore[page] = params uni.navigateTo({ url: pages[page] }) } 定义获取路由参数的方法:这里需要说明的是获取的路由参数应该是只读的,可以用vue的readonly方法包裹后返回:import { readonly, DeepReadonly } from 'vue' export function getRouteParams<T extends PageNames>( page: T, ): DeepReadonly<ObjectType<T>> { const p = routeStore[page] as ObjectType<T> return readonly(p) } 处理navigateTo方法处理用户多次点击let navigateLock = false function navigate<T extends PageNames>(page: T, params?: ObjectType<T>) { if (navigateLock) return navigateLock = true routeStore[page] = params uni.navigateTo({ url: pages[page], complete() { navigateLock = false }, }) } 路由返回增加参数要实现这个功能,我们可以借助uniapp跨页面通信API:uni.emit及uni.emit 及uni.emit及uni.once。(如果有不了解该的同学可以移步uniapp文档了解:点击这里)然后在调用navigateTo进行跳转时,将事件名传递给下个页面,下个页面在调用navigateBack返回时,通过传递过来的事件名调用uni.$emit触发事件。代码逻辑如下:let navigateLock = false function navigate<T extends PageNames>( page: T, params?: ObjectType<T>, ): Promise<any> { if (navigateLock) return const eventName = Math.floor(Math.random() * 1000) + new Date().getTime() + '' // 生成唯一事件名 navigateLock = true routeStore[page] = params uni.navigateTo({ url: `${pages[page]}?eventName=${eventName}`, // 这里将触发事件名传递给下个页面 complete() { navigateLock = false }, }) return new Promise<any>( (resolve, reject) => ( uni.$once(eventName, resolve), uni.$once(eventName, reject) ), ) } interface BackParams { /** 返回页面层级 */ delta: number /** 返回携带的数据 */ data: any } function back({ delta, data }: BackParams = { delta: 1, data: null }) { // 获取当前路由信息 const currentRoute = getCurrentPages().pop() // 拿到路由事件名参数 const eventName = currentRoute.options.eventName uni.$emit(eventName, data) uni.navigateBack({ delta, }) } 完整内容如下:import { CourseDetails } from './types' import pages from './pages' type PageNames = keyof typeof pages type ObjectType<T> = T extends 'courseDetails' ? CourseDetails : never const routeStore = {} as Record<PageNames, unknown> export function getRouteParams<T extends PageNames>( page: T, ): DeepReadonly<ObjectType<T>> { const p = routeStore[page] as ObjectType<T> return readonly(p) } let navigateLock = false function navigate<T extends PageNames>( page: T, params?: ObjectType<T>, ): Promise<any> { if (navigateLock) return const eventName = Math.floor(Math.random() * 1000) + new Date().getTime() // 生成唯一事件名 navigateLock = true routeStore[page] = params uni.navigateTo({ url: `${pages[page]}?eventName=${eventName}`, complete() { navigateLock = false }, }) return new Promise<any>( (resolve, reject) => ( uni.$once(eventName, resolve), uni.$once(eventName, reject) ), ) } function redirect<T extends PageNames>(page: T, params?: ObjectType<T>) { routeStore[page] = params uni.redirectTo({ url: pages[page] }) } function reLaunch<T extends PageNames>(page: T, params?: ObjectType<T>) { routeStore[page] = params uni.reLaunch({ url: pages[page] }) } function switchTab<T extends PageNames>(page: T, params?: ObjectType<T>) { routeStore[page] = params uni.switchTab({ url: pages[page] }) } interface BackParams { /** 返回页面层级 */ delta?: number /** 返回携带的数据 */ data?: any } function back({ delta, data }: BackParams = { delta: 1, data: null }) { const currentRoute = getCurrentPages().pop() const eventName = currentRoute.options.eventName uni.$emit(eventName, data) uni.navigateBack({ delta, }) } const router = { navigate, redirect, reLaunch, switchTab, back, } export default router 使用示例:路由跳转下一页获取路由参数返回携带参数function next() { router .navigate('courseDetails', { id: 1 }) .then((data) => { console.log('上个页面返回的数据') console.log(data) }) .catch((err) => { console.log(err) }) } function pageBack() { router.back({ data: { msg: '这是返回携带的数据', }, }) } 原生路由传参简化取参以上路由传参是假设在用户从首页打开小程序的情况下,但是有些场景下,如分享进入,消息通知点击进入,这时候携带的参数只能在url中。对此,uniapp也针对获取路由参数方式做了简化,可以通过定义 props 来直接接收 url 传入的参数,而不必在onload生命周期钩子中获取。<script setup> // 页面可以通过定义 props 来直接接收 url 传入的参数 // 如:uni.navigateTo({ url: '/pages/index/index?id=10' }) const props = defineProps({ id: String, }); console.log("id=" + props.id); // id=10 </script> 这时,对于一个多入口打开的页面,要采取哪种方式获取路由参数,可以根据场景值判断,详见文档场景值总结至此,我们已经完成了路由的优化封装目标。现在总结下使用步骤:pages.json中新增页面路径router -> pages.ts 新增页面别名配置如需传参,在types中增加参数类型接口router -> index.ts中修改ObjectType,如新增一个Order,ObjectType修改如下:type ObjectType<T> = T extends 'courseDetails' ? CourseDetails : T extends 'order' ? Order : never 作者:码克吐温 链接:https://juejin.cn/post/7120160996475289607 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
前言如今,小程序已经成了日常生活不可缺少的应用之一,掌握小程序开发对于前端来说,几乎是一个必备的技能。由于原生小程序语法开发体验差,缺乏生态插件等原因,已经诞生过许多第三方框架,如uniapp、taro、wepy、mpvue等,而随着时间的推移,uniapp及taro框架就如同现在vue及react两个主流前端框架一样,被大多数小程序开发者采用。对于使用vue技术栈的同学来说,想必都知道vue3已经如火如荼,vue主流生态都在“争先恐后”升级至vue3版本,而作为vue语法开发小程序的uniapp框架,也早已经跟上这波潮流,推出了vue3 + vite + setup + typescript开发小程序版本,不论在开发体验还是性能上,都带来了质的飞跃。(详见官方社区文章《vue3和vite双向加持,uni-app性能再次提升》)“新事物的诞生往往意味着新的开始”,在vue3 + vite + setup + typescript的未来趋势下,意味着我们需要对以前vue2版本学习的经验知识也要跟着做一番迭代,甚至推翻重来。接下来将从初始化代码项目开始,一步步带你基于uniapp + vue3 + vite + setup + typescript去搭建初始化一个小程序项目,在这过程中会引导你思考如何去做封装优化相关API方法等。举个例子,路由跳转微信官方API写法如下:wx.navigateTo({ url: '/pages/index/test?id=1', }) 这种写法平时写个demo倒还好,但在真实项目中涉及众多页面跳转,每次跳转都要写这么长的一段代码,这种体验是很差的。在路由封装模块章节中,经过封装的后的写法如下:router.navigate('index', {id: 1}) 这么一对比是不是一下子清爽很多了呢?你需要准备的前置知识请确保你已经学习过微信小程序基础知识,常见api和uniapp基础知识了解vue3基础知识,composition setup写法掌握typescript基础知识项目初始化全局安装vue-clinpm install -g @vue/cli@4 初始化代码npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project (如命令行创建失败,请直接访问 gitee下载模板)安装vscode插件使用script setup语法,需要配合volar插件使用,在vscode插件中搜索volar:然后选择Vue Volar extension Pack插件(该插件包含了vue3项目常用到的各种插件)。至此,我们的准备工作就算完成了!代码基建设置初始化项目的代码,还非常的简陋,此时我们不能急着立刻上手写业务代码,而是要先完成“基建”工作,只有基建搭好了,才方便后续项目代码的开发和维护。这些基建工作包括诸如:设置统一代码风格规范,路径别名,样式管理等,接下来就开始一步步实现。设置统一代码风格正所谓“无规矩不成方圆”,一个好的项目代码,必定是有着一定的代码规范约束。目前主流方案是使用Eslint + Prettier进行设置。安装Eslint 依赖在终端中输入:npm i @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-import eslint-plugin-prettier eslint-plugin-vue vue-eslint-parser -D 安装完依赖后,我们在根目录下新建.eslintrc.js文件,内容如下:module.exports = { root: true, env: { browser: true, node: true, es6: true, }, parser: 'vue-eslint-parser', parserOptions: { parser: '@typescript-eslint/parser', ecmaVersion: 2020, sourceType: 'module', jsxPragma: 'React', ecmaFeatures: { jsx: true, tsx: true, }, }, plugins: ['@typescript-eslint', 'prettier', 'import'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:vue/vue3-recommended', 'prettier', ], overrides: [ { files: ['*.ts', '*.tsx', '*.vue'], rules: { 'no-undef': 'off', }, }, ], rules: { 'no-restricted-syntax': ['error', 'LabeledStatement', 'WithStatement'], camelcase: ['error', { properties: 'never' }], 'no-var': 'error', 'no-empty': ['error', { allowEmptyCatch: true }], 'no-void': 'error', 'prefer-const': [ 'warn', { destructuring: 'all', ignoreReadBeforeAssign: true }, ], 'prefer-template': 'error', 'object-shorthand': [ 'error', 'always', { ignoreConstructors: false, avoidQuotes: true }, ], 'block-scoped-var': 'error', 'no-constant-condition': ['error', { checkLoops: false }], 'no-redeclare': 'off', '@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', }, ], 'no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', }, ], // vue 'vue/no-v-html': 'off', 'vue/require-default-prop': 'off', 'vue/require-explicit-emits': 'off', 'vue/multi-word-component-names': 'off', // prettier 'prettier/prettier': 'error', // import 'import/first': 'error', 'import/no-duplicates': 'error', 'import/order': [ 'error', { groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type', ], pathGroupsExcludedImportTypes: ['type'], }, ], }, } 新建.eslintignore文件创建ESLint忽略文件配置 .eslintignore,来指定我们不需要进行检查的目录或文件node_modules dist *.md *.woff *.ttf .vscode .idea 新建.prettierrc文件{ "semi": false, "tabWidth": 2, "trailingComma": "all", "singleQuote": true, "endOfLine": "auto" } 新建.prettierignore文件**/*.svg **/*.ico package.json package-lock.json /dist .DS_Store .eslintignore *.png .editorconfig .gitignore .prettierignore .eslintcache *.lock yarn-error.log **/node_modules/** vscode安装eslint跟prettier插件路径别名设置修改vite.config.ts,这里我们先设置两个别名,一个是针对src下代码文件,一个是针对图片静态文件,内容如下:import path from 'path' import { defineConfig } from 'vite' import uni from '@dcloudio/vite-plugin-uni' // https://vitejs.dev/config/ export default defineConfig({ plugins: [uni()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), '@img': path.resolve(__dirname, 'src/static/images'), }, }, }) 接着我们在.vue文件的template中可以这么写:<image class="logo" src="@img/logo.jpg" /> 假设我们要引入src -> router -> index.ts文件,在script里面这么写:可以看到,此时ts会报找不到模块的错误提示,此时我们需要在tsconfig.json文件做相关修改:在compilerOptions下添加 "paths": { "@/*": ["src/*"] } 即可。样式管理css预处理比较成熟的有sass,less,stylus,大家可以根据自己选择对应的css预处理器。这里以sass为例:先安装相关依赖npm i sass sass-loader -D 接着在src目录下创建styles文件夹,存放样式相关文件。新建vars.scss文件:管理颜色变量例如:$font-size: 28rpx; $primary-color: #54d339; 新建mixins.scss文件(以下示例供参考)例如@mixin flex-row { display: flex; align-items: center; } @mixin flex-column { display: flex; flex-direction: column; } // 文字超出隐藏 @mixin text-eli { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; } 新建common.scss:全局公共样式(以下示例供参考)@import "./vars.scss"; @import "./mixins.scss"; page { box-sizing: border-box; font-size: $font-size; } view, text { font-size: $font-size; box-sizing: border-box; color: #333; } // 去除按钮默认边框 button::after { border: none; } .flex-row { @include flex-row(); } .flex-row-between { @include flex-row(); justify-content: space-between; } .flex-row-around { @include flex-row(); justify-content: space-around; } .flex-row-center { @include flex-row(); justify-content: center; } .flex-row-end { @include flex-row(); justify-content: flex-end; } .flex-column { @include flex-column(); } .flex-column-center { @include flex-column(); align-items: center; justify-content: center; } .flex1 { flex: 1; height: 100%; } .text-line1 { @include text-eli(); -webkit-line-clamp: 1; } .text-line2 { @include text-eli(); -webkit-line-clamp: 2; } /* 间隔相关 */ .pad20 { padding: 20rpx; } .mb32 { margin-bottom: 32rpx; } .mb40 { margin-bottom: 40rpx; } .mt60 { margin-top: 60rpx; } .ml20 { margin-left: 20rpx; } .ml40 { margin-left: 40rpx; } /* 字体大小相关 */ .font24 { font-size: 24rpx; } .font48 { font-size: 48rpx; } .font36 { font-size: 36rpx; } .font32 { font-size: 32rpx; } .font-bold { font-weight: bold; } .text-center { text-align: center; } .text-white { color: #fff; } .text-color-main { color: $main; } .text-color-6 { color: #666; } .text-color-9 { color: #999; } .bg-white { background-color: #fff; } .bg-gray { background-color: $bg-gray; } App.vue文件中引入<style lang="scss"> /*全局公共样式 */ @import "./styles/common.scss"; </style> 配置自动导入颜色变量我们在vars.scss文件中定义的颜色变量,在页面中使用时,需要手动导入才能使用。那要怎么实现自动导入呢?我们可以在vite.config.js中配置即可:在return对象下新增:css: { preprocessorOptions: { scss: { additionalData: `@import "@/styles/vars.scss";`, }, }, } 这样我们在页面中就可以直接使用vars中定义的颜色变量了。此时还没完,我们还可以借助一个插件帮助我们识别定义的变量: SCSS IntelliSense在vscode中安装该插件后,如下图可以看到已经给出提示,开发体验又上升了一个台阶!自动导入vue方法vue3 script setup 写法中,组件间通信有defineProps跟defineEmits这种编译器宏方法,无需导入就可以直接使用。而对于vue当中导出的代码,我们还是需要手动显示引入,如下:import { computed, ref } from 'vue' const count = ref(0) 那有没有办法像defineProps等编译器宏方法一样,无需手动导入就可以直接使用呢?对此,我们可以使用unplugin-auto-import npm包实现。安装依赖包npm i -D unplugin-auto-import vite.config.ts中引入import AutoImport from 'unplugin-auto-import/vite' plugins: [ uni(), AutoImport({ imports: ['vue', 'uni-app'], dts: './auto-imports.d.ts', // 安装好依赖后,重新运行编译即可自动在根目录下生成此声明文件 }), ] tsconfig.ts中引入声明文件接着我们需要在tsconfig.ts文件include属性中引入声明文件,否则直接使用ts会报错。"include": [ "src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "auto-imports.d.ts" ] 接着我们就可以直接在代码中无需导入直接使用vue中方法了:// import { computed, ref } from 'vue' 这行代码不用写了 const count = ref(0) 总结至此,我们已经完成了项目代码初始化及基建工作(代码风格统一,样式管理,路由别名设置),并通过一些插件提升代码开发体验。当然这只是起始篇,接下来更有趣的请移步《状态管理篇》。
为了方便快速进行业务功能开发,通常会使用到第三方UI组件库。经过调研,发现以下三个UI组件库较为成熟,并可以在uniapp vue3框架下微信小程序中正常使用,它们之间的优缺点见如下:| | 优点 | 缺点 | 文档地址 | | --- | --- | --- || --- | |uni-ui| uniapp官方拓展组件库,组件数量丰富,高性能,跨平台;支持easycom自动引入使用到的组件 | 部分组件UI美观度欠佳 | 点击打开| |weui| 微信小程序官方拓展组件库,视觉体验与微信保持一致; 支持dark mode| 组件数量较少,更新迭代慢 | 点击打开| |vant-ui| 组件数量较为丰富,并提供一些开箱即用的复杂组件,如分享面板、侧边导航组件;支持主题定制,文档友好;社区活跃更新维护迭代快 | / | 点击打开|这里指的一提的是,uniapp下还有个优秀的跨平台UI组件库:uview。但可惜的是目前并不兼容uniapp vue3,暂时无法使用。至于实际项目需要使用哪个UI框架,大家可以自己需求选择判断。接下来分别介绍这三个UI组件库的引入使用方法:引入uni-ui安装npm i @dcloudio/uni-ui 配置easycom接着打开项目src目录下的 pages.json 并添加 easycom 节点:何为easycome?简单理解就是按需引入组件,打包后会自动剔除没有使用的组件,详情点击这里{ "easycom": { "autoscan": true, "custom": { "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue" } }, "pages": [] } 在 template 中使用组件<uni-badge text="1"></uni-badge> 引入weui由于是微信“亲儿子”,weui有特有的通过 useExtendedLib 扩展库 的方式引入,这种方式引入的好处是不会计入代码包大小。但是经过测试,uniapp 不支持该方式引入,若通过该方式引入组件,虽然页面上可能显示正常,但是会在编译时丢失传入组件的属性,也就是无法修改组件属性。经查,uniapp微信小程序组件必须放在src -> wxcomponents目录下。也就是说,我们需要将weui组件手动放入到wxcomponents目录下。步骤如下:首先我们需要获取到weui组件,然而github仓库并没有打包好供直接引入使用的组件,这时我们可以在微信开发者工具新建一个项目,接着在该目录下先npm init -y 接着npm i weui-miniprogram -S 安装好之后,我们点击上面的工具->构建npm接着就能看到新增了miniprogram_npm目录,我们将该目录下的weui-miniprogram拷贝到我们的uniapp项目目录下,也就是上面说的src -> wxcomponents, 并重新命名为weui接着我们在使用到的页面中需要手动引入组件,例如引入搜索框组件,修改pages.json: "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "uni-app", "usingComponents": { "mp-searchbar": "/wxcomponents/weui/searchbar/searchbar" } } }, ] 在 template 中使用组件<template> <mp-searchbar :value="search" @input="searchInput" ></mp-searchbar> </template> <script setup lang="ts"> import { ref } from 'vue' const search = ref('') function searchInput(e) { search.value = e.detail.value } <style lang="scss"> 运行效果:引入vant-ui同样的方式,我们需要获取到组件代码,点击vant-ui github仓库地址,将dist目录拷贝到wxcomponents文件夹下,并重新命名为vant。pages.json中引入接着我们在pages.json中手动引入使用到的组件,如引入button: "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "uni-app", "usingComponents": { "van-button": "/wxcomponents/vant/button/index", } } }, ] 开启js编译ES5要注意的,这里我们需要到本地设置中开启js编译ES5,否则会报错。在 template 中使用组件<van-button type="default" @click="">默认按钮</van-button> 注意事项这种组件源码引入的方式,uniapp在编译后的dist目录下生成的文件,并不会像uni-ui通过easycome方式引入自动剔除没有使用到的组件。不过不要紧,微信开发者工具给我们提供了过滤没有使用到文件的功能。点击右上角的详情,切换本地设置选项,然后把“上传时过滤无依赖文件”勾选上:接着我们点击预览,可以看到提示自动过滤了无依赖文件引入tailwind.css关于tailwind.csstailwind.css在爆火之初,不乏许多批评反对的声音,然而现在从star数来看,相信这种纯类名编写样式的写法已经被许多人接受&项目引入使用。其中我觉得很重要的一个原因正如官网的对其的描述:通常我们在编写页面样式时,分两个步骤:编写类名 + 跳转到编写样式区其中编写类名尤其让人头疼,要想写好类名每次还要花一定时间思考类名到底应该叫什么,不同英语水平的人最终导致编写的类名“百花齐放”。跳转到编写样式区就同样让开发体验不佳,尤其是在.vue单文件文件中,样式往往都是写在最底部,修改样式需要反复横跳。(虽然.vue文件在vscode可以一键拆分三个区域,但是依旧有痛点:一是视线依旧需要离开html,二是在笔记本小屏幕中开发这种拆分显示区域过小,通常不会拆分窗口)vue3 compistion组合式api的写法,带来的很大开发体验优势就是不用像之前那样写在固定地方,导致代码量大的情况下查找需要反复横跳,这么一看,是不是感觉它们之间有异曲同工之妙呢?
对于一些复杂的页面,往往可以添加功能引导蒙层帮助用户快速上手功能。如下图:原理显然,我们需要获取到操作按钮在页面当中的位置坐标及大小,然后在对应位置上方设置一个透明可视区域,这个可以利用css box-shadow属性来创建阴影蒙层。接着,对于有多个步骤引导的,在切换下一个时为了不那么生硬,需要添加动画过渡效果,这里可以借助微信小程序的animate API实现。封装组件一个功能页当中的代码,应该尽可能只保留该页面功能业务相关逻辑代码,对于用户引导这种与业务无关的代码应该抽离到外部组件当中,下面就来带大家如何封装实现。可以看到,一个提示引导有两部分组成:操作区域+操作提示。通常设计,操作提示往往是一张图片素材,但是这里为了简化代码,改为纯文字描述,在理解了原理之后大家可以自己在组件增加属性配置即可。在src->components目录下,新建user-guide.vue组件,组件代码如下:<template> <view v-if="show" class="user-guide"> <view id="visual-view" class="visual-view"> <view class="tip">{{ currentTip.tip }}</view> </view> <view class="btn-list"> <button class="btn" @click="close">知道了</button> <button v-if="!isEnd" class="btn next" @click="moveView">下一步</button> </view> </view> </template> <script setup lang="ts"> import { ref, getCurrentInstance, onUpdated } from 'vue' interface IProps { /** 是否显示 */ show: boolean /** 提示信息列表 */ list: TipItem[] } interface TipItem { /** 操作区域位置坐标及大小 */ width: number height: number top: number left: number /** 操作提示内容 */ tip: string } const props = defineProps<IProps>() const emit = defineEmits(['update:show']) const step = ref(0) const isEnd = computed(() => step.value === props.list.length - 1) let isFirstUpdate = false onUpdated(() => { if (!isFirstUpdate) { // 初始化第一个提示 currentTip.value = props.list[0] isFirstUpdate = true } }) // 关闭提示 function close() { emit('update:show', false) } // @ts-ignore const { ctx } = getCurrentInstance() // 给个初始值,否则template中渲染对象报错 const currentTip = ref<TipItem>({ width: 0, height: 0, top: 0, left: 0, tip: '', }) // 切换下一个 function moveView() { const preTip = currentTip.value step.value += 1 currentTip.value = props.list[step.value] const nextTip = currentTip.value ctx.$scope.animate( '#visual-view', [ { top: `${preTip.top}px`, left: `${preTip.left}px`, width: `${preTip.width}px`, height: `${preTip.height}px`, }, { top: `${nextTip.top}px`, left: `${nextTip.left}px`, width: `${nextTip.width}px`, height: `${nextTip.height}px`, }, ], 300, // 动画过渡时间 () => { // 调用 animate API 后会在节点上新增一些样式属性覆盖掉原有的对应样式,在动画结束后需要清除增加的属性 ctx.$scope.clearAnimation() }, ) } </script> <style lang="scss"> .user-guide { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 9; .visual-view { position: absolute; width: 200rpx; height: 100rpx; background: transparent; border-radius: 10px; box-shadow: 0 0 0 1999px rgba(0, 0, 0, 0.55); z-index: 10; .tip { position: absolute; z-index: 11; bottom: -30px; color: #fff; } } .guide-btn { position: absolute; z-index: 11; bottom: 200px; } } .btn-list { position: fixed; bottom: 100px; display: flex; width: 100%; justify-content: center; z-index: 11; .btn { margin: 0; &.next { color: #5080ff; margin-left: 10px; } } } </style> 需要说明的是,vue3 compositon写法中是没有this写法的,如果我们要获取挂载在this上面的API,可以通过import { getCurrentInstance } from 'vue' const { ctx } = getCurrentInstance() 拿到。而uniapp 小程序端this属性或方法是挂载到ctx下的$scope属性上,所以moveView方法中才有了如下写法: ctx.$scope.animate() 页面中使用组件<template> <view id="tip-one" >点击区域一</view> <view id="tip-two" >点击区域二</view> <view id="tip-three" >点击区域三</view> <UserGuide v-model:show="showGuide" :list="guideList" /> </template> <script setup lang="ts"> import { ref } from 'vue' import UserGuide from '@/components/user-guide.vue' interface TipItem { /** 操作区域位置坐标及大小 */ width: number height: number top: number left: number /** 操作提示内容 */ tip: string } const showGuide = ref(true) const guideList = ref<TipItem[]>([]) // 假设我们页面中需要提示三个操作区域,提示内容如下: const guideTips = ['点击这里赚积分', '点击这里得礼品', '点击分享赚...'] // 获取点击区域在页面中的坐标信息 const query = uni.createSelectorQuery() query.select('#tip-one').boundingClientRect() query.select('#tip-two').boundingClientRect() query.select('#tip-three').boundingClientRect() query.exec((res) => { guideList.value = res.map((item: TipItem, index: number) => { item.tip = guideTips[index] return item }) }) </script> 值得一提的是,在vue3中,已经移除了.sync修饰符,改为v-model:propName写法,如上面代码中v-model:show运行效果:总结本文实现了用户指引蒙层组件的封装,介绍了uniapp中如何使用微信小程序挂载在this下的动画方法api及如何获取页面元素坐标位置信息大小,大家理解原理后可以根据自己的需求改造增加组件配置属性即可。
Pinia是什么Pinia是vue团队推荐的下一代状态管理方案,相比之前的vuex方案,Pinia具有以下特点:配合vue3 Componsition API写法,更可靠的TypeScript 类型推断支持Pinia 没有 Mutations,可以直接修改state数据,Actions 支持同步和异步提供扁平结构,没有模块的嵌套结构等安装依赖npm i pinia -S 接着src目录下创建stores文件夹(注意:这里用复数形式命名以强调pinia的多状态实例的特性,也就是上面提到的特点3)初始化工作创建index.ts文件:stores文件夹下新建index.ts:import { createPinia } from 'pinia' const pinia = createPinia() export default pinia 在main.ts中引用:import { createSSRApp } from 'vue' import App from './App.vue' import pinia from './store' export function createApp() { const app = createSSRApp(App) app.use(pinia) return { app, } } 以上就完成了初始化工作,下面我们定义一个user模块说明如何使用定义user模块在stores目录下新建user文件夹,在其目录下我们新建两个文件:index.ts和types.ts(管理数据结构)index.ts:import { defineStore } from 'pinia' import { RootState } from './types' export const useUserStore = defineStore('user', { state: (): RootState => ({ userInfo: {}, token: '', }), getters: { // 示例返回大写字符 capName(state) { return state.userInfo.name.toUpperCase() }, }, actions: { async setUserInfo() { // 这里可以发起请求 const userInfo = await getUserInfo() this.userInfo = userInfo }, }, }) defineStore方法第一个参数“user”是模块的名称,值必须是唯一的(多个模块不能重名)state:箭头函数,返回一个对象数据getters:可以理解为计算属性,对state中的数据做进一步计算处理actions:封装业务逻辑,同步/异步修改state数据值得一提的是,对于store模块命名写法,有个约定俗成的写法:使用“use”+ 功能模块名称,如上面的useUserStore页面中使用<template> <view>{userStore.userInfo.name}</view> </template> <script setup lang="ts"> import { useUserStore } from '@/stores/user' const userStore = useUserStore() console.log(userStore.userInfo) </script> 解构state如果要使用解构写法获取值,而又不丢失响应式,我们需要用到storeToRefs方法const { userInfo } = storeToRefs(userStore) userInfo.value.name = 'username' 修改state直接修改:userStore.userInfo = {}$patch批量修改(性能更好):// $patch有两种写法 // 传入对象:适合同时修改多个不复杂的数据 store.$patch({ userInfo: {}, token: '', }) // 传入函数写法:上面传入对象写法在遇到复杂数据时,成本很高 //(例如,从数组中推送、删除、拼接元素)都需要创建一个新集合,这时就可以传入一个函数 cartStore.$patch((state) => { state.items.push({ name: 'shoes', quantity: 1 }) state.hasChanged = true }) $reset恢复初始值:例如我们在state中定义一个userInfo初始值userInfo: { name: '默认用户名', avatar: '默认用户头像' } 假设用户登录后我们修改了上面的用户信息,退出登录时,我们可以直接调用userStore.$reset()恢复初始值。不过要注意的是,它恢复的是整个state值,并不能只恢复state下面的单个值。对此,我们可以将有恢复初始值的需求的变量,用一个变量存储然后在需要恢复时,可以在action中定义一个方法重新赋值即可。监听状态如果你需要监听状态做一些处理,例如将数据持久化到本地,可以使用$subscribe()方法。userStore.$subscribe((mutations, state) => { uni.setStorageSync('userInfo', state.userInfo) }) 数据持久化插件:pinia-plugin-persistedstate在只需要本地存储一小部分字段时,可以通过上面监听状态钩子简易实现。但如果需要存储大量数据字段,则可以使用与之搭配的插件:pinia-plugin-persistedstate安装npm i pinia-plugin-persistedstate 修改stores/index.tsimport { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) export default pinia user模块export const useUserStore = defineStore('user', { state: (): RootState => ({ userInfo: { id: null, name: '', }, token: '', userName: '', }), persist: { key: 'store-key', // 本地存储key storage: { setItem: uni.setStorageSync, getItem: uni.getStorageSync, }, }, actions: { setName(name: string) { this.userName = name }, }, }) 打开微信开发者工具可以看到:可以看到上面存储的是整个user模块的state数据,如果只需要存储state下的某些字段,可以这么写: persist: { key: 'store-key', paths: ['userInfo.name'], storage: { setItem: uni.setStorageSync, getItem: uni.getStorageSync, }, } 微信开发工具中显示如下:总结至此,已经完成了pinia状态管理使用及配合pinia-plugin-persistedstate插件实现数据本地存储,接下来请移步《网络请求封装篇》。
为何要封装本地存储API1、增加typescript数据类型提示:假设我们要获取本地数据,写法如下: const data = uni.getStorageSync(key) 这时我们输入key及访问data时,是获取不到类型提示的。2、增加响应式:假设我们获取到上面的data之后,对data做了一些修改,这时我们又需要手动再次调用更新到本地data = newValue uni.setStorageSync(key, data) 目标希望封装之后的方法,在修改data后,自动调用存储api更新到本地(借助vue watch钩子)。封装根据上面的目标,先直接看封装后的代码:import { ref, Ref, watch } from 'vue' interface Company { companyName: string companyId: number } type StorageKeys = 'company' | 'companyList' type ObjectType<T> = T extends 'company' ? Company : T extends 'companyList' ? Company[] : never export function setStorage<T extends StorageKeys>( key: T, data: ObjectType<T>, ): void { uni.setStorageSync(key, data) } export function getStorage<T extends StorageKeys>( key: T, initValue: any = '', ): ObjectType<T> { const data = uni.getStorageSync(key) || initValue return data as ObjectType<T> } export function getStorageRef<T extends StorageKeys>( key: T, initValue: any = '', ): Ref<ObjectType<T>> { const data = uni.getStorageSync(key) || initValue const result = ref(data) watch(result.value, () => { setStorage(key, result.value) }) return result as Ref<ObjectType<T>> } company | companyList: 示例数据,当前公司及公司列表StorageKeys:定义所有本地存储数据keysObjectType :定义key对应的数据结构setStorage:存储方法getStorage:根据key获取数据方法(非响应式),适合于非直接渲染到template的场景,例如在函数中进行运算getStorageRef:根据key获取数据方法(响应式),适合于直接渲染到template的场景,同时在修改数据时,无需手动调用存储api更新到本地使用<script setup lang="ts"> import { setStorage, getStorageRef } from "@/utils/storage" const companyList = getStorageRef("companyList", []) </script> 使用案例:搜索历史使用本地存储最常见的案例就是存储搜索历史,下面代码就是当用户点击搜索结果后的代码逻辑function next(item: Company) { const companyList = getStorageRef("companyList", []) const idx = companyList.value.findIndex(i => i.companyId === item.companyId) if (idx < 0) { companyList.value.unshift(item) } else { // 先删掉已有的,然后追加到前面(注意:这里不用手动调用存储方法更新到本地啦) companyList.value.splice(idx, 1) companyList.value.unshift(item) } } 总结以上封装只是用到了setStorageSync及setStorageSync两个方法,这两个已经能够满足我们绝大部分使用场景,如果你对本地存储api还有其他需求,如使用异步,开启加密存储(使用setStorage方法),可以自行添加封装方法即可。