# 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 调用功能
最近在个人开发的小程序中,想要改版设计一个好看点的滑动选择器,因为自带的滑动选择器实在是太太太丑了。自带的小程序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),需要源码的欢迎私信我获取呀~(^_-)
前言如今,小程序已经成了日常生活不可缺少的应用之一,掌握小程序开发对于前端来说,几乎是一个必备的技能。由于原生小程序语法开发体验差,缺乏生态插件等原因,已经诞生过许多第三方框架,如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) 总结至此,我们已经完成了项目代码初始化及基建工作(代码风格统一,样式管理,路由别名设置),并通过一些插件提升代码开发体验。当然这只是起始篇,接下来更有趣的请移步《状态管理篇》。
对于一些复杂的页面,往往可以添加功能引导蒙层帮助用户快速上手功能。如下图:原理显然,我们需要获取到操作按钮在页面当中的位置坐标及大小,然后在对应位置上方设置一个透明可视区域,这个可以利用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及如何获取页面元素坐标位置信息大小,大家理解原理后可以根据自己的需求改造增加组件配置属性即可。
前言在这一节,我们先回顾下小程序路由基础知识,然后针对小程序路由存在的问题做相关封装优化。小程序路由基础知识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 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
为了方便快速进行业务功能开发,通常会使用到第三方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的写法,带来的很大开发体验优势就是不用像之前那样写在固定地方,导致代码量大的情况下查找需要反复横跳,这么一看,是不是感觉它们之间有异曲同工之妙呢?
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方法),可以自行添加封装方法即可。
前言对于请求的封装,主要有以下目的:添加typescript类型提示在拦截器里面进行一些统一配置,如设置header、针对错误码统一提示等多入口场景下,在未登录时,在拦截器里完成无痕登录后再请求。(如从分享页面进入)其中第三点尤其重要,原因在于:小程序存在多个场景打开入口,如消息通知,公众号菜单栏,分享进入等,打开的页面也不尽相同,由于小程序特有的登录逻辑,登录需要调用wx.login API,很显然,在多入口场景下,我们不能在简单粗暴的每个打开的页面都去写一遍调用wx.login,再请求其他接口。在拦截器中完成无痕登录,便于我们调试接口:我们在调试一些层级较深的页面接口时,可以在微信开发者工具中选择编译指定的页面,这样我们就无需点击多次进入到需要调试接口的页面。但是如果没有在请求拦截中完成预先登录,我们往往也是无法直接请求调试该页面的接口。综上目的,这里选择使用PreQuest这个强大的请求库。PreQuest 是一套 JS 运行时的 HTTP 解决方案,它包含了一些针对不同 JS 运行平台的封装的请求库,并为这些请求库提供了一致的中间件、拦截器、全局配置等功能的体验,还针对诸如 Token 的添加,失效处理,无感知更新、接口缓存、错误重试等常见业务场景,提供了解决方案。可以点击官方文档先做了解安装该请求库针对不同的平台提供了不同的安装包,这里安装两个依赖包:@prequest/miniprogram: 小程序端的请求库@prequest/lock: 请求锁,token 处理的解决的方案npm i @prequest/miniprogram @prequest/lock -S 封装文件在src->utils目录下新建requst.ts文件import { PreQuest, create } from '@prequest/miniprogram' import Lock from '@prequest/lock' import { MiddlewareCallback } from '@prequest/types' import { useUserStore } from '@/stores/user' const userStore = useUserStore() // 这里将token放在pinia user模块中 declare module '@prequest/types' { interface PQRequest { skipTokenCheck?: boolean } } // 全局配置 PreQuest.defaults.baseURL = '请求域名' // 设置header PreQuest.defaults.header = {} const prequest = create(uni.request) // 无痕刷新中间件 const lock = new Lock({ getValue() { return Promise.resolve(userStore.token) }, setValue(token) { userStore.token = token }, clearValue() { userStore.token = '' }, }) const wrapper = Lock.createLockWrapper(lock) const refreshToken: MiddlewareCallback = async (ctx, next) => { if (ctx.request.skipTokenCheck) return next() const token = await wrapper( () => new Promise((resolve) => { uni.login({ async success(res) { if (res.code) { // 登录获取token接口 prequest('/login', { method: 'post', skipTokenCheck: true, data: { code: res.code }, }).then((res1) => resolve(res1.data.data.token)) // 注意这里根据后台返回的token结构取值 } }, }) }), ) if (ctx.request.header) { // header中统一设置token ctx.request.header['Authorization'] = `Bearer ${token}` } await next() } // 解析响应 const parse: MiddlewareCallback = async (ctx, next) => { await next() // 这里抛出异常,会被错误重试中间件捕获 const { statusCode } = ctx.response if (![200, 301, 302].includes(statusCode)) { // 在这里可以设置toast提示 throw new Error(`${statusCode}`) } } // 实例中间件 prequest.use(refreshToken).use(parse) export default prequest 请求接口统一管理首先,我们在src目录下新建api文件夹,专门存放管理请求接口。新建types.ts用来存放复用的数据结构,例如请求成功返回的数据结构// 假设接口响应通过格式 export interface ApiResp { code: number message: string data: any meta?: { pageSize: number total: number current: number } } 接着按照功能模块进行管理,比如我们有用户相关的接口集合,在api下新建user.ts和user.model.ts两个文件,.model文件用于定义接口interface,这里值得注意的是一个接口对应两个interface,分别定义请求参数及返回的数据结构,这里可以约定统一命名格式为:参数为“Parm”后缀,返回数据为“Resp”后缀,如下示例:user.model.tsimport { ApiResp } from './types' export interface GetUserListParm { position: number } export interface GetUserListResp extends ApiResp { data: GetListData[] } export interface GetUserListData { name: string position: number } user.tsimport * as UserModel from './user.model' import prerequest from '@/utils/request' class UserService { // 获取列表 static getList(params: UserModel.GetListParm) { return prerequest.post<UserModel.GetListResp>( '/list', { params }, ) } } export default UserService 上面文件定义了一个叫做getList的请求方法,GetUserListParm和GetUserListResp分别定义该请求的参数及返回数据结构页面中使用<script setup lang="ts"> import UserService from '@/api/user' async function getData() { const params = { position: 1, } const res = await UserService.getList(params) const { code, data } = res.data if (code === 0) { console.log(data) // 这里访问data会有类型提示 } } getData() </script> 至此,我们已经完成了基本网络请求的封装和使用。而除了普通的网络数据请求,我们还可能遇到上传下载文件的需求,这里我们也一并做统一封装处理。封装上传下载公共方法安装依赖拓展包npm i @prequest/miniprogram-addon -S 通常来说,后台提供的上传接口都是公共的,我们可以在api目录下新建个common.ts文件,里面存放一些公共请求方法,例如上传下载在type.ts中新增内容// 文件上传成功返回数据 export interface UploadResp { code: number msg: string data: { filename: string fileUrl: string } } 修改utils->request.ts,增加createUpload和createDownload的参数声明:declare module '@prequest/types' { interface PQRequest { name?: string url?: string filePath?: string formData?: Common skipTokenCheck?: boolean } } common.ts:import { createUpload, createDownload } from '@prequest/miniprogram-addon' import { UploadResp } from './types' class CommonService { // 上传文件 static uploadFile(filePath: string) { const upload = createUpload(uni.uploadFile, { name: 'imgFile', filePath, formData: { fileName: 'testName' }, }) return upload<UploadResp>('/fileUpload/imgUpload') } // 下载文件 static downloadFile(url: string) { const download = createDownload(uni.downloadFile, { url, }) return download(url) } } export default CommonService 页面中使用示例:import CommonService from '@/api/common' // 选择照片或视频 function chooseMedia(mediaType: 'image' | 'video' = 'image') { uni.chooseMedia({ count: 1, mediaType: [mediaType], sizeType: ['compressed'], maxDuration: 60, success(res) { const path = res.tempFiles.map((item) => item.tempFilePath) uploadFile(path[0]) }, fail() { // $toast("选取图片失败"); }, }) } async function uploadFile(path: string) { const res = await CommonService.uploadFile(path) const { code, data } = res.data if (code === 0) { // 上传成功 } } 总结以上内容完成了对数据请求交互及文件上传下载的封装使用,除了这些,该请求库还提供了其他一些请求处理中间件,例如请求缓存,请求错误重试,具体使用大家移步官方文档查阅即可。值得一提的是,很多时候,我们一加载页面需要并发请求多个接口,很多人习惯直接这么写:onload() { getData1() getData2() getData3() } 这时候我们可以借助Promise 的两个api进行优化:Promise.allSettled: 用于并发请求有多个彼此不依赖的异步任务Promise.all: 用于彼此相互依赖或者在其中任何一个reject时立即结束
如果对axios用法不熟悉的,可以参考axios官方文档去学习。可能很多人在封装axios的时候,不知道后端返回的一大坨数据到底应该如何处理,难道每个字段都声明一下?那不把我们前端累吐血了?今天我们就来讲讲怎么优雅的处理这个数据格式。axios二次封装首先我们引入axiosimport axios from 'axios 我们从axios点击去,看它的index.d.ts,可以看到axios暴露了这么几个方法:export interface AxiosStatic extends AxiosInstance { create(config?: CreateAxiosDefaults): AxiosInstance; Cancel: CancelStatic; CancelToken: CancelTokenStatic; Axios: typeof Axios; AxiosError: typeof AxiosError; HttpStatusCode: typeof HttpStatusCode; readonly VERSION: string; isCancel: typeof isCancel; all: typeof all; spread: typeof spread; isAxiosError: typeof isAxiosError; toFormData: typeof toFormData; formToJSON: typeof formToJSON; CanceledError: typeof CanceledError; AxiosHeaders: typeof AxiosHeaders; }create方法这里我们需要的是create方法,来生成axios创建的实例,然后它需要的参数我们可以在CreateAxiosDefaults找到,它是继承自AxiosRequestConfig,可以看到这里面是所有它的配置参数:export interface AxiosRequestConfig<D = any> { url?: string; method?: Method | string; baseURL?: string; transformRequest?: AxiosRequestTransformer | AxiosRequestTransformer[]; transformResponse?: AxiosResponseTransformer | AxiosResponseTransformer[]; headers?: (RawAxiosRequestHeaders & MethodsHeaders) | AxiosHeaders; params?: any; paramsSerializer?: ParamsSerializerOptions; data?: D; timeout?: Milliseconds; timeoutErrorMessage?: string; withCredentials?: boolean; adapter?: AxiosAdapterConfig | AxiosAdapterConfig[]; auth?: AxiosBasicCredentials; responseType?: ResponseType; responseEncoding?: responseEncoding | string; xsrfCookieName?: string; xsrfHeaderName?: string; onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void; maxContentLength?: number; validateStatus?: ((status: number) => boolean) | null; maxBodyLength?: number; maxRedirects?: number; maxRate?: number | [MaxUploadRate, MaxDownloadRate]; beforeRedirect?: (options: Record<string, any>, responseDetails: {headers: Record<string, string>}) => void; socketPath?: string | null; httpAgent?: any; httpsAgent?: any; proxy?: AxiosProxyConfig | false; cancelToken?: CancelToken; decompress?: boolean; transitional?: TransitionalOptions; signal?: GenericAbortSignal; insecureHTTPParser?: boolean; env?: { FormData?: new (...args: any[]) => object; }; formSerializer?: FormSerializerOptions; }我们使用axios.create先创建一个实例,然后配置它的baseUrl和timeout,const service: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API, timeout: ResultEnum.TIMEOUT as number, })实例的类型为AxiosInstance,可以在create声明的地方看到。请求拦截接下来做个请求拦截器,这里需要处理一下token:service.interceptors.request.use( (config) => { const userStore = useUserStore() const token = userStore.token if (token) { config.headers.token = token } return config }, (error: AxiosError) => { ElMessage.error(error.message) return Promise.reject(error) }, )这里的token是从store中拿过来的,由于我们的store做了持久化缓存,所以只要是缓存中有token,这里就能获取到了。响应拦截接下来就是响应拦截器,这里我们需要对响应返回的数据做处理。首先成请求成功的情况,我们需要对成功分成不同的情况处理。第一种token失效,当data.code === 203的时候,就意味着token过期或者不正确,需要清空store和缓存数据,然后跳转到登陆页。第二种是data.code非200的情况,直接返回给用户错误信息即可,第三种就是data.code=== 200,直接把data返回即可。service.interceptors.response.use( (response: AxiosResponse) => { const { data } = response // * 登陆失效(code == 203) if (data.code === ResultEnum.EXPIRE) { RESEETSTORE() ElMessage.error(data.message || ResultEnum.ERRMESSAGE) router.replace(LOGIN_URL) return Promise.reject(data) } if (data.code && data.code !== ResultEnum.SUCCESS) { ElMessage.error(data.message || ResultEnum.ERRMESSAGE) return Promise.reject(data) } return data } )对于响应错误的情况,我们也需要单独处理,然后提示不同的错误消息。这里需要根据不同状态码来判断,也就是response.status,下面是我对于常见的错误码的处理service.interceptors.response.use( (error: AxiosError) => { // 处理 HTTP 网络错误 let message = '' // HTTP 状态码 const status = error.response?.status switch (status) { case 403: message = '拒绝访问' break case 404: message = '请求地址错误' break case 500: message = '服务器故障' break default: message = '网络连接故障' } ElMessage.error(message) return Promise.reject(error) }, )封装的请求方法然后我们封装一下常见的几种请求方法,get、post、delete、putconst http = { get<T>( url: string, params?: object, config?: AxiosRequestConfig, ): Promise<ResultData<T>> { return service.get(url, { params, ...config }) }, post<T>( url: string, data?: object, config?: AxiosRequestConfig, ): Promise<ResultData<T>> { return service.post(url, data, config) }, put<T>( url: string, data?: object, config?: AxiosRequestConfig, ): Promise<ResultData<T>> { return service.put(url, data, config) }, delete<T>( url: string, data?: object, config?: AxiosRequestConfig, ): Promise<ResultData<T>> { return service.delete(url, { data, ...config }) }, }对于这几种方法,他们的接收参数的形式不一样的,拿get和post距离,一个接收的是params,一个接收的data,get方法要想传递query参数,就需要这样写:service.get(url, { params, ...config })因为它跟config是一体的,而post传递参数,跟config是分开的,就需要这样传:service.post(url, data, config)类型约束接下来我们来看看它们的类型是如何约束的。拿get方法来举例:get<T>( url: string, params?: object, config?: AxiosRequestConfig, ): Promise<ResultData<T>> { return service.get(url, { params, ...config }) },get方法接受了一个范型T,那这个T是从哪来的?我们再去看看在调用get方法的时候是怎么使用的:import http from '@/utils/http' import type { UserRes } from './types' /** * @description 获取后台用户分页列表(带搜索) * @param page * @param limit * @param username * @returns {<PageRes<AclUser.ResAclUserList>>} */ /** * 获取登录用户信息 */ export function getUserInfo() { return http.get<UserRes>('/admin/acl/index/info') }这个接口是用来获取用户登陆信息的,可以看到它传了一个UserRes,而UserRes是这样声明的:export interface UserRes { userId?: string name: string avatar: string buttons: string[] roles: string[] routes: string[] }再回过头去看看,T就是UserRes,它约束了接口返回的data里面的字段信息。再接着看,http.get()方法返回信息的时候这样约束的:get<T>(): Promise<ResultData<T>> {},它返回了一个Promise,然后通过范型的方式,接收了ResultData<T>,这里的ResultData长这样:// * 请求响应参数(不包含data) export interface Result { code: number message: string ok?: boolean } // * 请求响应参数(包含data) export interface ResultData<T = any> extends Result { data: T }这样一来是不是就清楚了?原来层层传递的范型最终约束的就是后端返回的整个json对象,我们再看看后端返回的数据格式就一目了然了:现在你对后端返回的数据如何进行约束是不是有了一定的了解了?接下来我们在看一个分页列表的接口如何进行约束的。import http from '@/utils/http' import type { PageRes } from '../types' import type { AclUser } from './types' /** * @description 获取后台用户分页列表(带搜索) * @param page * @param limit * @param username * @returns {<PageRes<AclUser.ResAclUserList>>} */ export function getAclUserList(params: AclUser.ReqAclUserListParams) { return http.get<PageRes<AclUser.ResAclUserList>>( `/admin/acl/user/${params.pageNum}/${params.pageSize}`, { username: params.username }, ) }首先我们看看后台返回的数据:data外面的我们前面已经约束了,现在data里面的这个分页格式我们是不是可以统一约束一下?也就是上面的PageRes,它长这样:// * 分页响应参数 export interface PageRes<T> { records: T[] pageNum?: number pageSize?: number total: number }这样我们就对一个分页格式的数据做了统一的约束,至于里面的records数据列表,我们就要单独进行约束,通过范型的形式传进去就可以了。我们再看看records是如何约束的:// * 分页请求参数 export interface ReqPage { pageNum: number pageSize: number } // * 用户管理模块 export namespace AclUser { export interface ReqAclUserListParams extends ReqPage { username?: string } export interface ResAclUserList { deleted: boolean gmtCreate: string gmtModified: string id: string nickName: string password: string roleName: string salt: null token: null username: string } }我们通过ResAclUserList来对里面的字段进行了约束。同样的接受分页参数的时候也进行了这样的约束。注意:不需要约束所有的后端字段,只需要把我们前端使用到的字段约束即可。上面所提到的data格式需要跟后端提前约定好,跟自己自己公司的实际情况做修改,这个不是统一标准的。OK,讲完了类型约束,下面我们再来说一下接口规范。接口规范1.数据格式数据格式需要跟后端进行约定,比如我这里的就是:{ code: number data: {} message: string ok: boolean }2.code规范200 // 成功 201 // 失败 203 // token失效 3.分页列表规范{ data: { pageNum: number pageSize: number total: number records: [] } }有的records叫做list,items都可以。4.api目录规范项目所有的接口都放在api/目录下面,按照分类放在不同的文件夹下。统一在api/index.ts中导出。共公类型放在api/types.ts中,非公共类型放在各自的文件夹下的types.ts中。下面是我的api目录划分:这样我们在使用的时候,只需要从import { xxx } from "@/api"引入即可。