![]() |
不拘小节的毛衣 · Python能不能实现像Origin中的那样 ...· 3 月前 · |
![]() |
强悍的双杠 · js byte数组转base64-掘金· 1 年前 · |
![]() |
博学的双杠 · 自定义文件上传功能实现方法 - 掘金· 1 年前 · |
![]() |
闷骚的木耳 · javascript - ...· 1 年前 · |
前言我们常见的链表中一般有3种类型的指针:指向下一个节点、指向上一个节点、尾节点指向头节点。在复杂链表中,每个节点除了拥有指向下一个节点的指针外,还会有一个指针用于指向链表中的任意节点或者null。本文就跟大家分享下如何复制一个复杂链表,欢迎各位感兴趣的开发者阅读本文。实现思路相信大多数看到这个问题的第一反应是把这个复制过程分成两步:遍历原始链表,复制每个节点。为复制链表设置每个节点的sibling指针。假设原始链表中某个节点N的sibling指针指向节点S,由于S在链表中可能在N的前面也可能在N的后面。所以要定位S的位置就需要从原始链表的头节点开始找。如果从头节点开始沿着next指针经过s步找到了了节点S,那么在复制链表上节点N'的sibling指针离复制链表的头节点的距离也是沿着next指针走s步。用这种方法我们就可以为复制链表上的每个节点设置sibling指针。(如下图所示:节点1与节点2的sibling指针设置过程)。 image-20221201204750352那么,对于一个含有n个节点的链表,定位每个节点的sibling指针都需要从链表头节点开始经过O(n)步才能找到,因此这种方法总的时间复杂度是O(n^2)。经过观察后,上述方法的时间主要花费在定位节点的sibling指针上,这一部分能否优化呢?聪明的开发者可能已经想到在第一步遍历链表的时候,用一个HashMap把包含有sibling指针的节点一一对应存储起来。第二步在设置sibling指针的时候,只需要O(1)的时间即可从HashMap中找到当前节点对应的值。我们用空间换取了时间,一个含有n个节点的链表,我们需要一个大小为O(n)的HashMap。时间复杂度降到了O(n)。那么,我们能否在不使用辅助空间的情况下实现O(n)的时间效率呢?我们再来换种思路,第一步在复制节点的时候,把复制后的节点跟到原始节点之后,即A->A'->B...,我们用N'表示复制后的节点,做完这步操作后,链表的结构如下图所示。 image-20221201214026229第二步我们设置复制出来的节点的sibling指针,假设原始链表上的N的sibling指向节点S,那么(如下图所示):其对应复制出来的N'是N的next指针指向的节点(N->N')同样的,S'也是S的next指针指向的节点(S->S') image-20221201223444080 进行到这里,相信大家已经看出规律了,上图的长链表中:奇数位置的节点是原始链表,偶数位置的节点是复制出来的节点。第三步我们把长链表拆分成两个链表:奇数位置的节点用next指针连接起来就为原始链表偶数位置的节点用next指针连接起来就为复制出来的链表 image-20221201225759588 我们将三步结合起来,就是复制链表的完整过程,做到了不使用额外的空间用O(n)的时间复杂度解决了此问题。实现代码捋清楚思路后,接下来我们来看下每一步的代码实现。复制节点遍历链表节点,对每个节点进行复制,用next指针连接N与N'节点。function cloneNodes(pHead: complexListNodeType): void { let pNode: complexListNodeType | undefined = pHead; while (pNode != null) { // 复制当前节点,创建新节点:a' const pCloned: complexListNodeType = { value: pNode.value, next: pNode.next, sibling: null // 原始节点的指针指向复制出来的新节点上: a->a' pNode.next = pCloned; // 继续遍历原始节点的下一个节点: a = b pNode = pCloned.next; }复制sibling指针遍历链表节点,获取N的next指针指向的N'节点,如果节点N有sibling指针,则取出其sibling指针的next指针指向的节点(S'),将N'的sibling指针指向S'。function connectSiblingNodes(pHead: complexListNodeType) { // 获取节点N' let pNode: complexListNodeType | undefined = pHead; while (pNode != null) { const pCloned: complexListNodeType | undefined = pNode.next; if (pNode.sibling != null && pCloned != null) { // N'->S' pCloned.sibling = pNode.sibling.next; if (pCloned != null) { pNode = pCloned.next; }拆分长链表遍历链表节点,声明两个指针分别指向原始节点与复制后的节点,逐步向后探索,直至将所有复制后的节点都提取出来。function reconnectNodes(pHead: complexListNodeType) { let pNode: complexListNodeType | undefined = pHead; let pClonedHead: complexListNodeType | null | undefined = null; let pClonedNode: complexListNodeType | null | undefined = null; // 节点置换 // N' = N.next // N = N'.next if (pNode != null && pNode.next) { pClonedHead = pClonedNode = pNode.next; pNode.next = pClonedNode.next; pNode = pNode.next; while (pNode != null && pClonedNode) { pClonedNode.next = pNode.next; pClonedNode = pClonedNode.next; if (pClonedNode) { pNode.next = pClonedNode.next; pNode = pNode.next; return pClonedHead; }组合起来解决问题接下来,我们只需要把三个函数组合起来即可解决这个问题。export function copyComplexLinkedList( linkedList: complexListNodeType ): complexListNodeType | null { // 复制每一个节点紧跟其后: a->a'->b->b'->...n' cloneNodes(linkedList); // 复制sibling节点 connectSiblingNodes(linkedList); // 拆出复制好的链表 return reconnectNodes(linkedList); }测试用例我们用文章中列举的例子来校验下上述代码能否正确解决问题。const complexLinkedList: complexListNodeType = { value: 1, next: { value: 2, next: { value: 3, next: { value: 4, next: { value: 5 // 设置sibling指针 insertSiblingNode(complexLinkedList, 1, 3); insertSiblingNode(complexLinkedList, 2, 5); insertSiblingNode(complexLinkedList, 4, 2); const result = copyComplexLinkedList(complexLinkedList); console.log("复制出来的链表", result);执行结果如下所示,正确的复制了链表出来。 image-20221202214706985 image-20221202214846526示例代码本文用到的代码完整版请移步:CopyComplexLinkedList.tsCopyComplexLinkedList-test.ts写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站,进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言这几天前端圈子有一个比较火的基建工具Turbopack,官方文档号称比vite快10倍,比webpack快700倍。今天正好有空,把官方提供的demo浅玩了一下,发现它并没有那么神。 image-20221027223245550环境搭建Turbopack目前还处于alpha阶段,暂时只在next 13中提供了支持,按照官方文档所述,我们只需执行如下命令即可完成项目的初始化。npx create-next-app --example with-turbopack --typescript image-20221027223756134启动项目一切准备就绪后,我们打开项目,执行package.json中的dev指令,即可启动项目。yarn run dev本以为会很顺利,但是它报错了。error - [rendering] [root of the dev server]/ Error during SSR Rendering timed out waiting for the Node.js process to connect image-20221027224023436在next仓库的issue区域找到了这个问题的解决方案,原来是我的node版本不对,他要求node版本必须是16,我本地的版本是14。 image-20221027224321930当我把node版本改为16.14.2之后,项目终于成功启动了。 image-20221027224607692 image-20221027224625952dev环境下响应较慢项目启动的速度是挺快的,只需要几十毫秒,首屏加载也很快,但是当我在页面上切换菜单时,发现它卡卡的,如下述视频所示。看了下控制台打印的日志,发现响应时间确实有点长。 image-20221027230226728编译项目执行package.json中的build指令,即可将项目打包。yarn run build image-20221027225518481 打包成功后,执行start指令,即可启动项目。yarn run start项目启动后,我们再来试下菜单栏切换时的响应速度,发现它很丝滑,感受不到一丁点卡顿,如下述视频所示:5项目代码整体使用下来,感觉它在dev环境下运行时,即时编译的速度并没有惊艳到我,路由切换时还是卡卡的,很影响开发体验,我还是继续用webpack吧。希望正式版本发布时,这个响应速度可以更快吧。本文所创建的项目已上传至GitHub,需要的开发者可自取:next-demo-project注意:项目中我用了Volta作为包管理工具,对此感兴趣的开发者可移步我的另一篇文章:强大的JavaScript工具管理器Volta写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站,进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言swift支线开启🤗,花了点时间学了下xcode,在git配置环节踩了一个小坑,本文记录下xcode中配置github的过程,欢迎各位感兴趣开发者阅读本文。实现过程首先,我们打开xcode的Preferences面板。 image-20221019223048024在打开的面板中,点开Accounts面板,点击加号来添加GitHub账号。 image-20221019223242165 在弹出的面板中,选择GitHub。 image-20221019223314641在GitHub中创建token完成上述操作后,你就看到如下所示的面板弹出:Account就是你的GitHub账户名Token需要打开GitHub进行创建 image-20221019223359930创建token我们打开浏览器,登录github,点击头像,在弹出的菜单中点击Settings。 image-20221019223816402 在打开的面板中选择Developer settings。 image-20221019224031234 在打开的面板中选择Personal access tokens下的Tokens (classic),选择Generate new token来创建一个token。 image-20221019224255375 image-20221019224313450
require不存在一切准备就绪后,按下了项目启动按钮,很快啊,651ms项目就启动了,不愧是vite速度就是快,嘴角疯狂上扬。 image-20220804230003937浏览器加载完项目后,我傻眼了,我的登陆界面呢🌚?顺势打开控制台,发现报错require is not defined。 image-20220804230914786解决方案打开Login.vue文件后,发现我用require导入了一些图片文件,在VueCLI环境下的require会交给webpack处理。在vite中是不存在的,那么我们就需要查看vite是怎么处理静态文件了。翻了下文档后,在静态资源处理章节发现他有两种处理方法:通过import语句直接导入图片通过new URL来导入图片我打算将所有组件都重构为setup形式,因此直接使用import方式来导入图片可以保持组件的一致性,可以大大提升可读性。我们写个简单的demo来尝试下,如下所示:<template> <img :src="loginUndo" alt="" /> </template> <script lang="ts" setup> import loginUndo from "@/assets/img/login/LoginWindow_BigDefaultHeadImage@2x.png"; </script> <style scoped></style>已经可以正确解析出图片的路径了。 image-20220804234223781注意:本文不会过多讲解setup的语法,对此不了解的开发者请移步:单文件组件 - script setupnew URL方式可以用来引入一个动态资源,例如:你有一份json配置文件,里面描述了图片的文件名,这些图片是放在项目中的,他们的访问前缀都一样,此时你就可以通过遍历json文件通过此方式来引入这些图片。vue相关模块不存在我试图从vue的包中导入shallowRef时,编辑器报错: TS2305: Module 'xxx' has no exported member 'shallowRef'. 。 image-20220806102302026解决方案经过一番排查后,是因为项目typescript版本是3.x,跟3.2版本的vue不兼容,需要将其升级至4.x版本。打开package.json文件,作出如下所示的修改,重新执行yarn install命令即可。{ "devDependencies": { - "typescript": "~3.9.3", + "typescript": "~4.7.4", }setup中的变量警告未被使用当我在setup中声明了一个函数或者导入了一个文件,在template中已经使用了,但是他却报错ESLint: 'xx' is assigned a value but never used.(@typescript-eslint/no-unused-vars) image-20220806231446097解决方案在 eslint-plugin-vue 插件的Issues中看到有人遇到了跟我同样的问题,在v9.0.0: regression in unused variables in script setup中我找到了解决方案。我们需要升级下@vue/eslint-config-typescript和eslint-plugin-vue的版本号,如下所示:{ "devDependencies": { "@vue/eslint-config-typescript": "^11.0.0", "eslint-plugin-vue": "^9.0.0" }随后在eslint的配置文件中,添加parser属性,重新执行yarn install命令即可。module.exports = { + parser: 'vue-eslint-parser' }模块隔离Vite 使用 esbuild 来转译 TypeScript,并受限于单文件转译的限制,因此需要在ts的配置文件中将isolatedModules属性设置为true。{ "compilerOptions": { "isolatedModules": true }process不存在在路由配置文件中,我们需要从process中获取BASE_URL,此时编辑器报错: TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try npm i --save-dev @types/node and then add 'node' to the types field in your tsconfig. image-20220806105226383解决方案由于vite中已经没有process了,需要用import.meta来代替,那么上述的路由配置文件就应该改为:const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), // 地址栏不带# routes });无法导入json文件在表情面板模块,我将每个表情都放入了json文件中。在vite中引入文件需要使用import,改了写法后,发现它报错:Cannot find module 'xx.json'. Consider using '--resolveJsonModule' to import module with '.json' extension. image-20220806111708308解决方案我们需要在ts的配置文件中添加resolveJsonModule属性,如下所示:{ "compilerOptions": { + "resolveJsonModule": true }使用vite提供的对象当我想使用vite所提供的glob属性时,发现编辑器报错: TS2339: Property 'glob' does not exist on type 'ImportMeta'.解决方案也很简单,我们只需要在ts的配置文件中添加vite/client即可,如下所示:{ "compilerOptions": { "types": [ + "vite/client" }获取全局属性当我们使用一些第三方库的时候它会在globalProperties挂载一些方法,当在ts+setup环境下使用时,会出现类型无法推导问题,如下所示:第三方库提供了一个$connect方法我们通过proxy来访问<script lang="ts" setup> import { getCurrentInstance, onMounted, ComponentInternalInstance } from "vue"; onMounted(() => { const { proxy } = getCurrentInstance() as ComponentInternalInstance; proxy.$connect(); </script>他会出现报错: TS2339: Property 'xx' does not exist on type 'ComponentPublicInstance{}, {}, {}, {}, {}, {}, {}, {}, false, ComponentOptionsBase >'. image-20220809103616969解决方案我们可以在type目录下新建一个global文件夹,在这里存放一些我们扩展出来的全局方法。如下所示,我们:创建了一个useCurrentInstance方法将globalProperties属性暴露出去import { ComponentInternalInstance, getCurrentInstance } from "vue"; export default function useCurrentInstance() { const { appContext } = getCurrentInstance() as ComponentInternalInstance; const proxy = appContext.config.globalProperties; return { proxy }我们在组件中使用暴露出来的proxy即可,如下所示:<script lang="ts" setup> import useCurrentInstance from "@/type/global/UseCurrentInstance"; onMounted(() => { const { proxy } = useCurrentInstance(); proxy.$connect(); </script>无法识别NodeJS类型我们在给setinterval和setTimeout指定类型时,会用到NodeJS模块,会出现报错:ESLint: 'NodeJS' is not defined.(no-undef)。这个问题的解决方案是:打开eslint的配置文件在globals对象中添加NodeJS选项,如下所示:{ globals: { NodeJS: true }除了将类型声明为NodeJS.Timeout外,我们还可以将其声明为number类型,但是需要携带window前缀(window.setinterval/window.setTimeout)管理静态资源当我们在组件中使用import导入很多静态资源时,组件看起来会很杂乱。此时我们可以将其按照功能类型进行拆分。我的做法如下:在src下创建resource文件夹根据功能类型创建ts文件,将其导出import defaultAvatar from "@/assets/img/login/LoginWindow_BigDefaultHeadImage@2x.png"; import defaultLoginBtnIcon from "@/assets/img/login/icon-enter-undo@2x.png"; import loginUndo from "@/assets/img/login/icon-enter-undo@2x.png"; import loginBtnHover from "@/assets/img/login/icon-enter-hover@2x.png"; import loginBtnDown from "@/assets/img/login/icon-enter-down@2x.png"; export { defaultAvatar, defaultLoginBtnIcon, loginUndo, loginBtnHover, loginBtnDown }; image-20220808212416992 分离模版与逻辑代码我的项目中有一个很复杂的组件,有上千行代码,去年我用CompositionAPI优化了一版,将组件中所有的方法都拆分成了一个个独立的ts文件,做到了逻辑代码与模版代码分离,模版需要什么方法我就通过import导入进来,最后return给模版。在拆分出来的文件中,是没有办法访问vue提供的一些内置属性的,比如:defineProps、defineEmits、getCurrentInstance。因此我想了一个奇妙的方法:将这些无法访问的属性都存起来。具体的做法请移步我另一篇文章:使用Vue3的CompositionAPI来优化代码量-创建InitData.ts文件适配方案vue3.2的setup语法糖支持import进来的方法都能在模版中直接使用,那我们的组件又可以精简下了,我花了亿点点时间对其进行了适配🤒之前我们想获取组件的emit需要从context中拿,props声明并从setup函数的参数中获取,如下所示:<script> export default defineComponent({ name: "message-display", props: { listId: String, // 消息id messageStatus: Number, // 消息类型 buddyId: String, // 好友id buddyName: String, // 好友昵称 serverTime: String // 服务器时间 setup(props, context) { // 访问emit context.emit </script>现在我们就不用这么麻烦了,直接通过defineProps、defineEmits获取即可,如下所示:<script lang="ts" setup> // 获取父组件传递值 const props = defineProps<{ listId: string; // 消息id messageStatus: number; // 消息类型 buddyId: string; // 好友id buddyName: string; // 好友昵称 serverTime: string; // 服务器时间 }>(); const emit = defineEmits<{ e: "update-last-message", msgObj: { text: string; id: string; time: string; ): void; }>(); // 事件监听函数,传入props和emit将其存储到initData中 const { userID, onlineUsers } = eventMonitoring(props, emit) as { userID: ComputedRef<string>; onlineUsers: ComputedRef<number>; </script>此组件重构后的完整代码请移步:message-display.vueEventMonitoring.ts项目地址至此,项目的重构工作就结束了。本文重构好的项目代码地址:chat-system写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站,进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言截止发文时间,vite正式版已经发布快2年时间了,vue3也发布到3.2版本了,它的周边设施基本上已经齐活了。也是时候再次重构下我那个vue3.0的开源项目了。本篇文章就记录下我的重构过程,欢迎各位感兴趣的开发者阅读本文。环境搭建1年多前,我用Vue Cli 4.5构建的此项目,有关此项目的更多细节请移步我的另一篇文章使用Vue3重构Vue2项目。同样的,从CLI迁移到Vite仍然是在package.json中添加vite的依赖项,在项目中添加它的配置文件。此次项目构建还加入了volta的相关配置,对此感兴趣的开发者请移步:强大的JavaScript工具管理器Volta新增vite相关依赖项我们打开package.json,找到devDependencies字段,移除CLI相关的依赖,添加vite相关的依赖,如下所示:+绿色标识代表新增-红色标识代表移除{ "dependencies": { - "compression-webpack-plugin": "^5.0.1", "devDependencies": { + "@vitejs/plugin-vue": "^3.0.0", + "vite": "^3.0.0", + "vue-tsc": "^0.38.4", + "@types/node": "^18.6.3", - "sass-loader": "^8.0.2", - "@vue/cli-plugin-babel": "~4.5.0", - "@vue/cli-plugin-eslint": "~4.5.0", - "@vue/cli-plugin-router": "~4.5.0", - "@vue/cli-plugin-typescript": "~4.5.0", - "@vue/cli-plugin-vuex": "~4.5.0", - "@vue/cli-service": "~4.5.0", - "@vue/compiler-sfc": "^3.0.0-0" }随后,我们找到scripts字段,修改项目的运行与构建命令。{ "scripts": { "serve": "vite --open", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview" }vite3.x版本要求node版本必须大于14.18.0,因此我们需要在engines字段中做一下提示,如下所示:{ "engines": { "npm": "please-use-yarn", "yarn": ">= 1.0.0", "node": ">= 14.18.0" }除了上述配置外,我们还需要在项目的根目录创建.npmrc文件,写入下述内容:engine-strict = true配置完成后,我们执行在终端执行yarn install安装依赖即可。在上述配置中,我们还强制设置了yarn作为项目的包管理工具,如果项目开发成员使用了npm install则不会开始安装依赖并提示其使用yarn来安装依赖。添加vite配置文件在vite中,index.html已经从public文件夹迁移到项目的根目录下了,官方文档对此的解释为:在开发期间 Vite 是一个服务器,而 index.html 是该 Vite 项目的入口文件。有关此变更的详细解释请移步:index.html 与项目根目录接下来,我们在项目的根目录创建index.html文件(将public目录下的文件删除)引入静态文件时不需要使用%PUBLIC_URL%作为占位符,可以直接写/来访问,vite会将其解析到public根目录下通过<script type="module" src="...">标签直接指向Vue的入口文件(文件后缀可以为js或者ts)<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>chat-system</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>注意:如果你的项目比较复杂,有多个入口,那么就将index.html文件放到对应入口的根目录下。最后,我们创建vite.config.ts文件,配置代码如下所示:设置开发环境的端口号设置路径别名设置打包后base地址以及打包输出目录import { defineConfig } from "vite"; import { resolve } from "path"; import vue from "@vitejs/plugin-vue"; const IS_PRODUCTION = process.env.NODE_ENV === "production"; export default defineConfig({ plugins: [vue()], server: { host: true, port: 8020, proxy: {} resolve: { // 设置路径别名 alias: { "@": resolve(__dirname, "./src"), "*": resolve("") base: IS_PRODUCTION ? "/chat-system" : "./", define: { "process.env": {} build: { outDir: resolve(__dirname, "dist") });注意:我的项目配置比较简单,它只有一个入口,打包后只会部署到生产环境。如果你的项目较为复杂,也不必太过担心,你的应用场景vite也是支持的,按照文档进行相关的配置就好,如下所示:自定义构建多页面应用模式环境变量和模式当你的项目有多个入口时,期望通过不同命令来启动不同项目时,你可以使用yarn的--cwd指令来指定其运行时的工作目录。例如:你有两个入口,那么就在src目录下创建两个文件夹:**A、B **。A和B中分别有自己的index.html、main.ts以及package.json文件(配置start、build命令,传入不同的参数来启动/构建不同入口的项目)根目录的package.json中你就可以配置启动/构建命令为:{ "scripts": { "dev:A": "yarn --cwd ./src/A run start", "dev:B": "yarn --cwd ./src/B run start", "build:A": "yarn --cwd ./src/A run build", "build:B": "yarn --cwd ./src/B run build", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview" }最后,我们以A入口为例,列举下package.json文件中的配置:{ "name": "A", "version": "1.0.0", "main": "index.js", "license": "MIT", "scripts": { "start": "vite serve --config ../../vite.config-A.ts --mode development", "build": "vue-tsc --noEmit && vite build --config ../../vite.config-A.ts --mode production" }升级Vue周边依赖项vue3.2的单文件组件引入了setup规范,它可以让代码变得更简洁,可以使用纯 TypeScript 声明 props 和抛出事件,有着更好的运行时性能。这些优点让我有了升级vue版本的动力,之前的3.0版本写起来很臃肿,需要return一大堆东西,甚是麻烦。打开package.json文件作出下述变动:更新了vue、router、vuex的版本号新增了vueuse包,这是一个基于 Composition API 的实用函数集合,封装了一些常用的功能(实时获取鼠标位置、防抖、节流、获取客户端系统主题等),可以避免一些重复性的工作内容,大大提升开发效率。{ "dependencies": { - "vue": "^3.0.0-0", - "vue-class-component": "^8.0.0-0" - "vue-router": "^4.0.0-0", - "vuex": "^4.0.0-0", + "vue": "^3.2.37", + "vue-router": "^4.1.3", + "vuex": "^4.0.2", + "@vueuse/components": "^8.9.2", + "@vueuse/core": "^8.9.2" }最后执行yarn install即可完成整个环境的搭建,本章节重构完成后的完整文件请移步:.npmrcindex.htmlpackage.jsonvite.config.ts经验分享本章节就跟大家分享下,我切到新环境后做的一些优化点以及遇到的问题和解决方案。本章节修改到的文件,完整文件代码如下:package.jsontsconfig.json
前言webp是谷歌推出的一种图像格式,它可以在保持同样质量的情况下,体积比JPG少40%,可以很大程度的节省带宽使用,提升网站的加载速度。由于它是新推出不久的格式,对于一些比较旧的浏览器,它是不支持的。那么有没有办法让支持此格式的浏览器加载webp图片,让不支持的浏览器加载正常图片呢?本文就跟大家分享一种解决方案,欢迎各位感兴趣的开发者阅读本文。思路分析我们想实现这个需求,首先得需要有一个能将普通的图片格式转换为webp格式的程序,经过一番寻找后,找到了一个名为webp_server_go的开源项目。转换程序我们有了,那么如何动态调用这个程序呢?我们的需求是根据客户端的情况来决定是否要返回webp格式的图片,那么我们就可以在nginx中通过反向代理来实现动态调用。我们来梳理下思路:nginx拦截客户端请求,将请求反向代理到webp_server_gowebp_server_go收到请求后,读取http_header中的浏览器信息,决定是否要返回webp格式的图片最后,nginx将webp_server_go返回的内容发给浏览器编译转换程序webp_server_go的releases页面提供了linux的安装包,如果你的运行环境正好满足条件,可以跳过此章节,直接下载即可。这个程序采用go语言编写,因此需要安装go的开发环境,由于安装过程较为简单,本文不做讲解。按照教程搭建好环境后,我们把项目clone到本地,目录如下所示: image-20220518213243428 我们打开Makefile文件(推荐使用GoLand来打开),执行文件里面的default命令 image-20220518213515632如果你的编辑器不支持点击图标来运行,那么你可以在终端进入项目的根目录,按照顺序执行如下所示的命令:make clean go build -o builds/webp-server-$(OS)-$(ARCH) .执行成功后,你会在builds目录下看到编译出来的适用于你当前系统的应用程序(如果你的系统是macos,看到的结果会和我的一样,如果是windows看到的则是一个exe文件)。 image-20220518214528472运行转换程序按照官网列举的使用方法,我们在用户的根目录创建一个名为webp-server的文件夹,将准备好的转换程序复制进去并在其目录下创建一个名为config.json文件,写入如下所示的内容:HOST 启动后的服务地址PORT 服务端口号QUALITY 转换后的图片质量MAX_JOB_COUNT 最大并发转换量IMG_PATH 图片存储路径(客户端访问图片资源时的存储目录)EXHAUST_PATH 转换为webp后的图片存储路径(客户端请求资源时会优先从这里找,找不到才会触发转换程序,转换完成后会存储到此处)ALLOWED_TYPES 需要处理的图片格式{ "HOST": "127.0.0.1", "PORT": "8082", "QUALITY": "80", "MAX_JOB_COUNT": "10", "IMG_PATH": "/Volumes/DataStorage/fileStorage", "EXHAUST_PATH": "/Volumes/DataStorage/fileStorage/uploads/cache", "ALLOWED_TYPES": ["jpg", "png", "jpeg", "bmp"] }最后,在终端执行如下所示的命令来启动转换程序:./webpServer --config=config.json看到如下所示的界面时,就代表此服务已经正常工作了。 image-20220518225743246注意:大多数情况下你应该是需要此程序静默运行的,不希望看到这个运行框,那么你就需要用到nohup命令来执行程序了。例如:nohup ./webpServer --config=config.json > /Volumes/DataStorage/logs/webpServer-log.txt &对此命令不熟悉的开发者,请移步Linux nohup 命令作进一步了解。反向代理客户端请求做完上述配置后,我们就可以配置nginx来拦截客户端的请求,将其反向代理至我们上一步搭建的好服务上,配置如下所示:所有携带包含uploads的请求全部交给webpserver进行处理# 反向代理uploads目录下的图片至webpserver进行格式转换 location ~ ^/(uploads)/ { proxy_pass http://127.0.0.1:8082; proxy_set_header X-Real-IP $remote_addr; proxy_hide_header X-Powered-By; proxy_set_header HOST $http_host; }最后,我们访问网站来看下是否成功,如下所示: image-20220518231447321返回的图片已经是webp格式了 image-20220518231527544写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言为了更好的阅读体验,你可以直接滑到最下方,点击阅读原文进行查看。最近花了点时间基于Halo把自己的网站改造了下,实现了站内的文章阅读、点赞、评论、留言板等功能,本文就跟大家介绍并分享下我改造后的网站[1],欢迎各位感兴趣的开发者阅读本文。环境搭建Halo[2]是一款现代化的开源博客/CMS系统,官网[3]列举了详细的环境搭建教程[4],按着官网给出的教程一步步往下走,即可完成安装,过程很顺利,此处不做过多赘述。对我改造好的网站比较感兴趣的开发者,请移步:在线地址[5]自定义数据库如果你有一定的Java/SpringBoot/Gradle基础,希望对搭建好的环境进行更深层次的定制,你可以继续阅读本章节,否则跳过即可。Halo默认采用H2作为数据库,因为我本地装有MySQL,为了方便管理,我决定把它改掉,在文档的数据库章节[6]提供了配置方案,我们需要将Halo的源码[7]clone到本地,打开application.yaml文件,删除H2相关的配置,加入如下所示的配置:spring: datasource: # MySQL database configuration. driver-class-name: com.mysql.cj.jdbc.Driver # 修改成你的数据库地址 url: jdbc:mysql://127.0.0.1:3306/halodb?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true # 数据库的用户名与密码 username: root password: 123456注意:halo目前只支持mysql且版本必须大于等于5.7。自定义缓存本章节也需要你有一定的Java/SpringBoot/Gradle基础,否则跳过即可。Halo的默认缓存策略是到内存中的,它支持redis缓存方式,我本地装有redis,因此也一并修改了,在文档的缓存章节[8]提供了配置方案,打开application.yaml文件,修改cache字段值为redis,并加入redis的连接配置,如下所示:spring: redis: # redis端口号 port: 6379 database: 0 # redis地址 host: 127.0.0.1 # redis密码 password: 123456 halo: cache: redis编译项目做完上述修改后,我们需要对项目进行编译(注意你的jdk版本必须大于等于11),打开idea中的Gradle面板,执行build命令的jar即可。 image-20220506000934441编译过程中可能会看到一些test的报错,它不影响最终打包结果,可以忽略不计,打包成功后,在项目的build/libs目录下即可看到打包出来的文件。 image-20220506001324907最后,拿着打包出来的去运行即可(同样的,运行环境的jdk>=11)。选择主题环境搭建完毕后,在浏览器输入http://127.0.0.1:8090即可看到初始化界面了,根据页面提示一步步的往下进行即可。由于默认界面是比较丑的,halo提供了主题仓库[9],里面有许多第三方主题,可以挑选一个好看的进行改造,我挑选的是Joe 2.0主题[10],基于它进行了改造,在线地址[11],最终效果如下所示:注意:如果的halo是自己编译的,在管理后台安装第三方主题时,可能会出现报错:当前主题仅支持 Halo ^1.x.xx 及以上的版本,求助这个主题的作者后,他说这个校验逻辑在后端,只能通过手动下载Release[12] 页面的主题包,手动解压上传到halo安装目录的/templates/themes/目录下,并将文件夹命名为joe2.0。 image-20220506003306253最后,在后台管理界面启用主题即可。 image-20220506003442183 GPU占用严重问题如果你使用的是Joe 2.0主题,在浏览器打开你的网站超过5分钟,你的电脑风扇会狂响,cpu温度持续升高。 image-20220506003957266 image-20220506004108767经过一番排查后,终于定位到了问题:人生倒计时插件的锅,可能这里的代码写的不好,造成了大量运算,在网站管理后台的主题 -> 主题设置 -> 侧边栏 展示人生倒计时,将其关掉即可。 image-20220506004444333改造后的网站接下来,跟大家介绍下我的个人网站[13]中都有哪些内容。首页首页有4个模块,如下所示:轮播图区域:此处会按时间循环展示我最新发表的5篇文章侧边栏区域:展示我的头像、昵称、专栏数、文章数、社交平台等信息推荐专栏区域:会按照专栏的点击量来展示6个热度最高的专栏文章列表区域:包含最新文章、热门文章、最近更新、最多点赞四个分类,默认展示最新文章动态此处将分享一些我的日常生活状态、一些简短的想法等内容🤗 image-20220506005503508文章此处将以时间轴的形式展示我所发布过的文章。 image-20220506005610659专栏此处将展示我创建的所有专栏(即:文章分类)。标签此处将展示文章发布时所创建的标签。 image-20220506010006880留言板如果有想对我说的话,欢迎在此留言🤓 image-20220506010200337关于我此处将展示一些我的个人介绍。 image-20220506011426299写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[14],进一步了解。文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊文中链接可从文末参考资料中获取
前言有一个整数数组,我们想按照特定规则对数组中的元素进行排序,比如:数组中的所有奇数位于数组的前半部分。本文将带大家实现这个算法,欢迎各位感兴趣的开发者阅读本文。实现思路我们通过一个实例来分析下:假设有这样一个数组:[2, 4, 5, 6, 7, 8, 9, 11],将奇数移动到最前面后,就是:[11, 9, 5, 7, 6, 8, 4, 2]。通过观察后,我们发现在扫描这个数组的时候,如果发现有偶数出现在奇数的前面, 就交换他们的顺序,交换之后就符合要求了。因此,我们可以维护两个指针:第一个指针初始化时指向数组的第一个数字,它只向后移动;第二个指针初始化时指向数组的最后一个数字,它只向前移动;在两个指针相遇之前,第一个指针总是位于第二个指针的前面。如果第一个指针指向的数字是偶数,并且第二个指针指向的数字是奇数,则交换这两个数字。接下来,我们来通过图来描述下上述例子交换指针的过程,如下所示:第一个指针永远指向偶数,如果不为偶数就向后移动;第二个指针永远指向奇数,如果不为奇数就向前移动;当两个指针各自指向的数都符合条件时,就交换两个元素的位置;交换完成后,重复上述步骤,直至两个指针相遇或者第一个指针位于第二个指针之后则代表问题已得到解决。 image-20220418224313591实现代码有了思路之后,我们来看下实现代码,如下所示:export class AdjustArrayOrder { // 指向数组元素的两个指针:一个指向数组头部、一个指向数组尾部 private begin = 0; private end = 0; // 调整数组中奇数与偶数元素的位置:奇数位于偶数前面 reorderOddEven(arr: Array<number>): void { this.end = arr.length - 1; while (this.begin < this.end) { // 向后移动begin(转成二进制跟1做与运算,运算结果为0就表示为偶数),直至其指向偶数 while (this.begin < this.end && (arr[this.begin] & 0x1) !== 0) { this.begin++; // 向前移动end(转成二进制跟1做与运算,运算结果为1就表示为奇数),直至其指向奇数 while (this.begin < this.end && (arr[this.end] & 0x1) === 0) { this.end--; // begin指向了偶数,end指向了奇数 if (this.begin < this.end) { // 交换两个元素的顺序 [arr[this.begin], arr[this.end]] = [arr[this.end], arr[this.begin]]; // 重置指针位置 this.begin = 0; this.end = 0; }代码的可扩展性如果数组中的元素不按照奇前偶后排列,我们需要将其按照大小进行划分,所有负数都排在非负数的前面,应该怎么做?聪明的开发者可能已经想到了方案:双指针的思路还是不变,我们只需修改内层while循环的的判断条件即可。这样回答没有问题,确实解决了这个问题,那么如果再改改题目,我们需要把数组中的元素分为两部分,能被3整除的数都在不能被3整除的数前面,应该怎么做?经过思考后,我们发现这个问题无论再怎么改变都有一个共同的部分:双指针的逻辑永远不会变。变化的只是判断条件,那么我们就可以把变化的部分提取成函数,当作参数让调用者传进来,这样就完美的解决了这个问题,也正是我们所提及的代码的可扩展性。最后,我们来看下实现代码,如下所示:// 元素排序 reorder(arr: Array<number>, checkFun: (checkVal: number) => boolean): void { this.end = arr.length - 1; while (this.begin < this.end) { // 向后移动begin while (this.begin < this.end && !checkFun(arr[this.begin])) { this.begin++; // 向前移动end while (this.begin < this.end && checkFun(arr[this.end])) { this.end--; // begin与end都指向了正确的位置 if (this.begin < this.end) { // 交换两个元素的顺序 [arr[this.begin], arr[this.end]] = [arr[this.end], arr[this.begin]]; }测试用例我们先来测试下奇数在偶数之前的函数处理代码能否正常执行,如下所示:const adjustArrayOrder = new AdjustArrayOrder(); // 奇数在前 const arr = [2, 4, 5, 6, 7, 8, 9, 11]; adjustArrayOrder.reorderOddEven(arr); console.log(arr);执行结果如下所示: image-20220418230700388最后,我们来测试下reorder函数能否正常执行:负数在数组的最前面// 负数在前 const checkMinusNumber = function (val: number) { return val > 0; const arr = [2, 4, 5, 6, 7, -8, -10 - 12, -2]; adjustArrayOrder.reorder(arr, checkMinusNumber); console.log(arr); image-20220418230947578能被3整除的数在数组的最前面const checkDivisible = function (val: number) { return val % 3 !== 0; const arr = [2, 4, 5, 6, 3, 6, 9, 12]; adjustArrayOrder.reorder(arr, checkDivisible); console.log(arr); image-20220418231124400 示例代码文中所举代码的完整版请移步:AdjustArrayOrder.ts[1]adjustArrayOrder-test.ts[2]写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[3],进一步了解。文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊文中链接可从文末参考资料中获取
前言昨天在看webstorm的更新日志时,发现它添加了对Volta的集成,第一眼看到时比较好奇这是个啥,于是就第一时间上手体验了一波。经过一番摸索后,知道了它的作用以及解决了哪些开发痛点,本文就跟大家分享下这个强大的JavaScript工具管理器Volta[1]的安装与使用,欢迎各位感兴趣的开发者阅读本文。环境搭建Volta使用rust开发,没有任何外部依赖项,安装起来特别容易。在macos与linux系统上安装打开终端,执行如下所示的命令:curl https://get.volta.sh | bash安装成功后的界面如下所示: image-20220412221024653细心的开发者可能已经发现,安装完成后volta命令还无法使用,这是因为添加到环境变量中后,还未生效,我们需要执行如下所示的命令来让其生效:source ~/.bash_profile最后,我们再次执行volta命令它就可以正常使用了。 image-20220412222000931在Windows系统上安装在Windows上安装需要下载: volta安装包[2],按照提示安装即可。 image-20220412222601999一直点next即可完成安装,我们打开cmd或者powershell执行volta指令来验证下是否生效,如下所示: image-20220412222945842使用场景环境搭建完毕之后,接下来我们看下它的使用场景。在项目中管理全局JS包版本对于前端开发者来说,打交道最多的就是Node与各种包管理工具(yarn、pnpm等等),此时你作为一个团队的技术领导,想统一团队成员电脑上安装的软件包版本,通常做法就是将运行项目所需的版本号写进README.md文件中,团队成员自己来安装对应版本的软件。这种方法显然是不爽的,当有多个项目时,每个项目依赖的 node版本 都不一样,就需要每次打开项目看下该项目的版本号,手动去切换 node版本 ,降低了开发效率。volta的出现解决了这个痛点,你只需要在项目的package.json中添加volta字段,写上版本号如下所示:"volta": { "node": "14.16.0", "yarn": "1.22.17" }团队成员只需在他的电脑上搭建好volta的环境,启动项目即可顺利完成版本的切换🤓,如下图所示,我们分别在项目根目录和全局位置的终端执行了node --version,得到了不同的结果,就证明版本已经切换成功了。 image-20220412232649661更多用法在官方文档中,它还提供了更多的使用方法,你可以用它来充当你系统的JS包版本管理平台,用它来管理node、typescript、nest等等一系列基于JS开发的包。我们最常用的就三个命令:install 安装一个包,使用方法如下:# @用于指定版本号,可忽略,忽略的话则默认安装最新版本 volta install node@12.11.1list 显示当前项目使用的全局JS包版本号,使用方法如下:volta list image-20220412234457717pin 切换package.json中写的包版本号,使用方法如下:volta pin node@12.11.1 image-20220412234333745❝更多指令请移步官方文档:volta-reference[3]写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[4],进一步了解。文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊文中链接可从文末参考资料中获取
前言有这样一个对象,它有两个属性:name与title,在赋值的时候这两个属性只有一个能出现,例如:name出现的时候title就不能出现,title出现的时候name就不能出现。此时,你会怎么用TypeScript来定义这个类型?本文将带大家实现一个互斥类型来解决这个问题,欢迎各位感兴趣的开发者阅读本文。前置知识在实现之前,我们需要先来了解几个基础的知识。对象中多属性同类型的定义有一个对象它包含5个可选属性a、b、c、d、e,他们的类型都为string,大多数人的定义方式应该如下所示:type obj = { a?:string; b?:string; c?:string; d?:string; e?:string; }那么,有没有更好的方式呢😼,答案是有的,请看我的表演:type obj = { [P in "a" | "b" | "c" | "d" | "e"]?: string };never类型在TypeScript中它有一个特殊的类型never,它是所有类型的子类型,无法再进行细分,也就意味着除了其本身没有类型可以再分配给它。我们举个例子来解释下上述话语,如下所示:我们定义了一个变量amazing,给其赋予了never类型。我们分别给它赋了不同类型的值,全部编译失败,因为它无法再进行细分了。let amazing: never; amazing = 12;// 报错:amazing是never类型不能分配给number类型 amazing = true;// 报错:amazing是never类型不能分配给boolean类型 amazing = "真神奇";// 报错:amazing是never类型不能分配给string类型 amazing = {};// 报错:amazing是never类型不能分配给{}类型 amazing = [];// 报错:amazing是never类型不能分配给[]类型剔除联合类型中的属性有一组联合类型"a" | "b" | "c" | "d",我们想剔除属性b和c,在TS中提供了一个名为Exclude的函数,它可以用来做这件事,接受两个参数:UnionType 联合类型ExcludedMembers 需要进行剔除的属性使用方法如下所示:type P = Exclude<"a" | "b" | "c" | "d", "b" | "c"> // "a" | "d"将对象中的所有属性转为联合类型有一个对象它包含2个可选属性name、title,我们想把它转为联合类型name | title ,在TS中提供了一个名为keyof的函数,他可以用来处理这个问题,使用方法如下所示:type A = { [P in "name" | "title"]?: string }; type UnionType = keyof A; // "name" | "title"实现互斥类型有了前置知识作为铺垫,接下来我们就可以将其利用起来,定义一个互斥类型出来,解决文章开头所讲述的问题。接下来,我们来梳理下实现思路:实现一个排除类型,用于从A对象类型中剔除B对象类型中的属性,并将排除后的属性类型设为never,得到一个新对象类型。基于排除类型实现互斥类型,将A、B对象类型代入排除类型中,彼此将其排除,用或运算符将二者结果连接。❝聪明的开发者可能已经猜到原理了,没错,就是部分属性设为never。🤓实现代码接下来,我们来看下代码的实现,如下所示:// 定义排除类型:将U从T中剔除, keyof 会取出T与U的所有键, 限定P的取值范围为T中的所有键, 并将其类型设为never type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }; // 定义互斥类型,T或U只有一个能出现(互相剔除时,被剔除方必须存在) type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T);❝注意:为了类型的可复用性,我们使用了泛型,对此不熟悉的开发者请移步:TypeScript中文网——泛型[1]测试用例我们将文章开头所说的问题代入上述实现代码中,看一下它能否将其解决😌,如下所示:// A类型 type A = { name: string; // B类型 type B = { title: string; // A和B两种类型只有一个能出现 type AOrB = XOR<A, B>; // 传值测试 const AOrB1: AOrB = { name: "姓名" }; // 编译通过 const AOrB2: AOrB = { title: "标题" }; // 编译通过 const AOrB3: AOrB = { title: "标题", name: "姓名" }; // 报错: Type '{ title: string; name: string; }' is not assignable to type 'AOrB'. const AOrB4: AOrB = { name: "姓名", otherKey: "" }; // 报错:Type '{ name: string; otherKey: string; }' is not assignable to type 'AOrB'.当两个属性同时出现时,编辑器直接就抛出了类型错误(我们把排除后的所有属性的类型设为了never,因此当你给其赋任何值时它都会报类型错误),如下图所示: image-20220409221841105用例拆解有一部分开发者可能对上述测试用例比较懵,把它们拆开都认识,因为前置知识里都讲了,但是写到一起就不认识了😹,没关系,那我就把它们都拆解出来吧,代码如下所示:type AOB = ({ name?: never } & { title: string; }) | ({ title?: never } & { name: string; // 传值测试 const a: AOB = { name: "姓名" }; // 编译通过 const b: AOB = { title: "标题" }; // 编译通过 const c: AOB = { title: "标题", name: "姓名" }; // 报错 const d: AOB = { title: "标题", otherKey: "" }; // 报错❝看到这里,可能还有一部分开发者没有理解,那就动起手来在编辑器里敲一敲,如果还没理解的话,就先把这篇文章收藏,日后有时间了,再拿出来学一学。写在最后至此,文章就分享完毕了。·我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[2],进一步了解。文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊文中链接可从文末参考资料中获取
前言在正则表达式匹配规则中:.代表任意一个字符;* 代表它前面的字符可以出现任意次(含0次)。例如:字符串dpaaab与规则d.a*b匹配(所有字符匹配模式)。本文将带着大家实现这个匹配算法,欢迎各位感兴趣的开发者阅读本文。实现思路接下来,我们来分析下字符串与规则之间的比对思路:比对两个字符串同一位置的字符:同位置的字符相等或者当前位置的字符为.则满足相等条件规则字符数>1且当前字符串的下一位字符等于*,则执行下述两个条件,满足任意一个即可:字符串保持不变,从规则字符的下下位开始递归(*前面的字符可以出现任意次数,故从*后面开始寻找)进行比对获取结果同位置的字符符合相等条件且规则字符串保持不变从字符串的下一位开始递归进行比对获取结果否则,同位置的字符符合相等条件且从字符串与匹配字符的下一位开始递归进行比对获取结果我们将上述思路代入前言的例子中,它的递归栈就如下图所示: image-20220328220443088实现代码有了思路后,我们就可以愉快的写出代码了,如下所示(完整代码请从 示例代码 章节获取):/** * 匹配.与*的正则表达式 * 1. .代表可以匹配任意字符 * 2. *代表它前面的字符可以出现任意次数 * @param str * @param pattern public match(str: string, pattern: string): boolean { if (pattern.length === 0) { return str.length === 0; // 相同位置的字符相等或者当前位置的字符为.代表匹配成功 const matchResult = str.length > 0 && (str.charAt(0) === pattern.charAt(0) || pattern.charAt(0) === "."); // 有* if (pattern.length > 1 && pattern.charAt(1) === "*") { // *前面的字符可以出现任意次数,故:从*后面开始寻找递归寻找 return ( this.match(str, pattern.substring(2)) || (matchResult && this.match(str.substring(1), pattern)) } else { // 无* return matchResult && this.match(str.substring(1), pattern.substring(1)); }接下来,我们写一个测试用例,将前言中的例子代入,再举一个不符合条件的例子(完整代码请从 示例代码 章节获取)const regExprMatch = new RegExprMatch(); let result = regExprMatch.match("dpaaab", "d.a*b"); console.log("匹配结果", result); result = regExprMatch.match("dsaaap", "d.a*b"); console.log("匹配结果", result);执行结果如下所示: image-20220328221746809示例代码本文所用代码的完整版本请移步:RegExprMatch.ts[1]regExprMatch-test.ts[2]写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[3],进一步了解。文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊文中链接可从文末参考资料中获取
前言前几天在项目中集成了swagger,一切准备就绪打算将其部署到服务器时发现并不顺利,访问的时候页面白屏,由于我的nest项目采用的是单文件部署,互联网上没有找到相关的解决方案,于是我就成了第一个吃螃蟹的人。经过一番折腾后,终于解决了这个问题,本文就跟大家分享下我的解决方案,欢迎各位感兴趣的开发者阅读本文。集成Swagger首先,我们通过yarn安装三个依赖包,如下所示:yarn add @nestjs/swagger swagger-ui-express fastify-swagger安装完成后,我们打开项目的入口文件main.ts添加如下所示的代码:import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; async function bootstrap() { // **** 其它代码省略 **** const config = new DocumentBuilder() .setTitle("nest-demo") .setDescription("nest-demo项目的API使用文档") .setVersion("1.0.0") .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api", app, document); }接下来,我们启动项目,在浏览器访问http://127.0.0.1:3000/api,显示的界面如下所示:default选项列出了我们项目中的所有接口 image-20220317211550995通过注解编写接口文档在@nestjs/swagger库中,它提供了丰富的依赖供我们使用, 为我们生成友好的接口文档,接下来我们列举几个较为常用的注解:@ApiTags注解,用于对controller层进行描述。@ApiOperation注解,用于对controller中的具体接口进行描述。@ApiProperty注解,用于对dto层的参数进行描述。@ApiResponse注解,用于对接口的返回数据进行描述。关于上述各个注解的具体使用方法可参考我的项目代码,如下所示:AppController.ts[1]AppDto.ts[2]ResultVO[3]经过上述配置后 ,最终访问效果如下所示: image-20220317224923516❝有关swagger注解的更多使用方法请移步:OpenAPI (Swagger)[4]部署至服务器接下来,我们要做的就是将项目打包部署到服务器了,本项目采用的是单文件构建法,对此不了解的开发者请移步:Nest项目部署的最佳方式[5]。构建时遇到的问题因为集成了swagger进来,在打包时终端报错了ERROR in ./node_modules/@nestjs/mapped-types/dist/type-helpers.utils.js 69:27-63 Module not found: Error: Can't resolve 'class-transformer/storage' in ...经过一番查找后,在mapped-types仓库的Issues[6]中找到了答案,需要在webpack.config.js中的lazyImports中加入class-transformer/storage,打包的时候即可将其忽略,部分代码如下所示,完整代码请移步:module.exports = { entry: "./src/main", target: "node", // ** 其他配置代码省略 ** plugins: [ // 需要进行忽略的插件 new webpack.IgnorePlugin({ checkResource(resource) { const lazyImports = [ "@nestjs/microservices", "@nestjs/microservices/microservices-module", "@nestjs/websockets/socket-module", "cache-manager", "class-validator", "class-transformer", "class-transformer/storage" }❝完整代码请移步:webpack.config.js[7]部署时遇到的问题我们将项目部署到服务器,启动后,在浏览器通过127.0.0.1:3000/api访问swagger时发现页面一片空白,打开控制台后发现它的一些资源文件404了。 image-20220318072947623这可真是个棘手的问题,直觉告诉我肯定是因为我配置了单文件部署才导致的,我在求助了很多人,查了很多资料后,发现他们都没像我这么玩过,他们都是在服务器上npm install依赖来跑nest项目的。真是糟了个大糕🤡,我成了第一个吃螃蟹的人,前面一片蓝海等着我去探索。经过一番思考后,应该是因为webpack把所有依赖都打包进main.js了,swagger-ui引用的文件应该是相对路径的,所以才导致了404问题,抱着这个疑问,我打开了swagger-ui-express的源码,在index.js中发现了猫腻:它果然是引入的相对路径。 image-20220318074256928既然是相对路径,它自己的包下面又没有这个文件,那么它肯定是从别的包引入的。 image-20220318074447502继续查阅源码后,我发现它还require了一个swagger-ui-dist。 image-20220318074604930果然,它所依赖的资源包都在这个目录下,他为什么要这么做呢?我又抱着疑问打开了swagger-ui仓库,在docs/usage/installation.md[8]中它讲述了原因,提供了webpack的配置方案。 image-20220318075453246打开链接所指向的项目后,在webpack的配置文件中我看到了copy-webpack-plugin插件,此时我茅塞顿开,它的做法就是将swagger-ui-dist的文件拷贝到dist下,这样就解决了它相对路径找不到文件的问题。方案有了,那么就可以愉快的写出代码了,如下所示:const CopyWebpackPlugin = require("copy-webpack-plugin"); module.exports = { entry: "./src/main", target: "node", plugins: [ new CopyWebpackPlugin({ patterns: [ // 拷贝swagger相关的文件 from: __dirname + "/node_modules/swagger-ui-dist/", to: "./" }重新构建后,我们发现dist目录下多了swagger-ui-dist的文件,我们启动项目,重新在浏览器发现已经能正常看到swagger的界面了。 image-20220318111124109细心的开发者可能发现swagger-ui-dist目录下有很多无用文件,污染了我们的dist目录,我们需要将这些无用的文件在打包后清理掉。![image-20220318112446885](/Users/likai/Library/Application Support/typora-user-images/image-20220318112446885.png)接下来就该clean-webpack-plugin插件登场了,我们在webpack的配置文件中加入下述代码:cleanAfterEveryBuildPatterns 意为在构建完成之后删除文件const { CleanWebpackPlugin } = require("clean-webpack-plugin"); module.exports = { entry: "./src/main", target: "node", plugins: [ // ** 其它代码省略 ** // 删除多余的文件 new CleanWebpackPlugin({ cleanAfterEveryBuildPatterns: [ __dirname + "/dist/*.html", __dirname + "/dist/*.map", __dirname + "/dist/*.md", __dirname + "/dist/*.json", __dirname + "/dist/index.js", __dirname + "/dist/LICENSE", __dirname + "/dist/NOTICE" }现在dist目录看起来就舒服多了😼 image-20220318114857681 ❝注意:copy-webpack-plugin、clean-webpack-plugin需要用yarn自行安装到devDependencies依赖中。完整代码请移步:webpack.config.js[9]部署至YAPI最后,我们在yapi的数据管理模块,导入swagger数据过来,本以为很顺利,结果它报错:返回数据格式不是JSON。 image-20220318113759221翻阅文档后,我找到了方案[10],原来是要在地址后面加-json。加了之后,就能顺利的导入到yapi了,大功告成🤗 image-20220318114103459做第一个吃螃蟹的人太难了,为了解决这个问题,我在浏览器已经不知不觉的开了这么多标签页了🌚 image-20220318114447903项目代码本文所使用的完整代码,请移步项目的GitHub仓库:nest-project[11]写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[12],进一步了解。文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊文中链接可从文末参考资料中获取
前言前一阵子搞了个nest项目,当我开发完一个功能,打算部署到服务器进行测试时,发现它跑不起来,报了一大堆错缺少了很多依赖包。我几乎找遍了全网的解决方案,他们的答案齐刷刷只有一个:nest在打包时,不会将依赖打包进去,需要在服务器上clone项目,安装依赖。这个答案不是我想要的,在服务器上安装node_modules纯属胡闹。幸运的是,经过一番研究后,我终于解决了这个问题,本文就跟大家分享下我的实现思路与方案,欢迎各位感兴趣的开发者阅读本文。场景概述我们继续用文章“使用NestJS搭建服务端应用[1]”所创建的项目,以此为基础来描述这个问题,我们打开package.json文件,执行里面的build命令。{ "scripts": { "build": "nest build", }一眨眼的功夫,它就打包好了,在你的项目根目录下会多出一个dist文件夹,如下如所示,这就是它所打包出来的文件。 image-20220217225521052紧接着,我们把dist目录上传到服务器,用node来执行其目录下的main.js文件,上传文件至服务器后,我发现整个文件夹竟然只有18KB,我当时惊呆了,心想js这么牛的吗!开发出来的服务端应用包体积居然这么小,同样的功能使用Java实现,打包出来的jar包都50MB起步了! image-20220217230347949当我在服务器上运行时,我傻眼了,程序报错跑不起来🌚,这玩意儿不经夸啊。定位问题我怀着忐忑的心情打开dist的目录下的文件后,发现它只是简单的把ts编译成了js,并没有打包任何依赖包进去,他所有的依赖包都是从node_modules中引的。我们的服务器上是没有这些依赖包的,所以他就报错了。 image-20220217231732899在搜索引擎上找了下解决方案,千篇一律的要在服务器上clone项目,然后在服务器上安装庞大的node_modules,简直是无稽之谈。跟几个人交流后,他们说node项目本来就是这样啊,都是在服务器上安装依赖包的,这让我想起了好多年前看到的一个图,用在此处极为合适。 image-20220217232141650解决方案我是一个追求完美的人,这么庞大的一个开源库,设计者一定不会这么傻吧,这种低级问题应该早就考虑到了才对,既然网上找不到方案,那我就读一下它的源码吧。皇天不负有心人,当我在查阅nest-cli源码的打包模块时,在@nestjs/cli/actions/build.action.js文件中发现了它有个配置变量webpack。 image-20220218000128632随后,我在nest的官方文档中,在nest-build[2]章节找到了这个配置项的相关内容,发现他可以在打包命令后面添加--webpack参数来生成单文件main.js。 image-20220218000919121于是,我添加了这个参数,运行打包命令后,单文件是生成了,但是依赖文件依然没打包进去。出现这种情况那就只有一种可能了:nest-cli在打包时排除屏蔽了依赖包。顺藤摸瓜,我在@nestjs/cli/lib/compiler/defaults/webpack-defaults.js发现了猫腻,如下图所示:它使用webpack-node-externals插件屏蔽了依赖的打包。 image-20220218001802693 实现代码经过上面的分析,我们定位到了问题所在,既然它默认屏蔽了依赖的打包,那我们就自己创建一个webpack.config.js文件,忽略掉externals以及一些nest提供的插件,这个问题就完美解决了,实现代码如下所示:将externals属性置为空,就忽略掉了默认的webpack-node-externals插件使用IgnorePlugin忽略掉了nest中的一些无用依赖包/* eslint-disable @typescript-eslint/no-var-requires */ const path = require("path"); const webpack = require("webpack"); // fork-ts-checker-webpack-plugin需要单独安装 const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); module.exports = { entry: "./src/main", target: "node", // 置为空即可忽略webpack-node-externals插件 externals: {}, // ts文件的处理 module: { rules: [ test: /\.ts?$/, use: { loader: "ts-loader", options: { transpileOnly: true } exclude: /node_modules/ // 打包后的文件名称以及位置 output: { filename: "main.js", path: path.resolve(__dirname, "dist") resolve: { extensions: [".js", ".ts", ".json"] plugins: [ // 需要进行忽略的插件 new webpack.IgnorePlugin({ checkResource(resource) { const lazyImports = [ "@nestjs/microservices", "@nestjs/microservices/microservices-module", "@nestjs/websockets/socket-module", "cache-manager", "class-validator", "class-transformer" if (!lazyImports.includes(resource)) { return false; try { require.resolve(resource, { paths: [process.cwd()] } catch (err) { return true; return false; new ForkTsCheckerWebpackPlugin() };❝⚠️注意:上述webpack配置文件要求package.json中webpack的版本号为^5.11.0",还需要安装fork-ts-checker-webpack-plugin依赖包到devDependencies中。最后,我们修改打包命令为:{ "scripts": { "build": "nest build --webpack --webpackPath=./webpack.config.js", }执行上述命令后,我们发现依赖包已经打入main.js了,文件体积也上升到了3.6mb。 image-20220218004017593最后,我们用node来运行这个js文件,也没有了报错,顺利的跑起来了。 image-20220218004215022我们再拿postman来测试下接口能否正常访问,如下所示,也都可以正常访问。 image-20220218004354633❝小tips:在服务器上运行node项目时,通常会使用pm2来执行。对此感兴趣的开发者,请自行了解。示例代码本文中所列举的完整代码请移步:webpack.config.js[3]package.json[4]写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[5],进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言当我们在dto层定义好参数字段后,客户端在调用时传入了未定义的字段,此时我们需要报错告知客户端这个字段不存在,在nest中默认不会报错,本文将分享这个问题的解决方案,欢迎各位感兴趣的开发者阅读本文。场景概述我们继续用文章“使用NestJS搭建服务端应用[1]”所创建的项目,以此为基础来描述这个问题,如下所述代码所示,我们在AppDto.ts中定义了三个字段。idtitlenameexport class AppDto { @MinLength(5) @IsString() public id!: string; @IsString() public title!: string; @IsString() public name!: string; }随后,我们启动项目,使用postman调用接口,传多一个age字段,这个字段我们未曾在AppDto中定义,调用接口后,如下图所示,接口调用成功了,这并不是我们的期望结果,我们希望它报错。 image-20220214230136474❝小tips:在Java中,我们在实体类中定义了字段,SpringBoot在处理客户端参数,对其进行序列化时,就可以直接抛出异常。解决方案在解决这个问题时,我在网络上检索了一波,没发现合适的方案,最后,求助了一波网友,得到的方案是自己在controller层写方法遍历参数的所有key对其进行校验,然后抛出异常。我觉得这是下下策,自己写方法校验太繁琐了,不利于维护。尝试解决突然,有个网友告诉了我forbidUnknownValues这个关键词,打开了我的眼界,让我看到了希望。 image-20220214231807475经过一番检索后,找到了有关它的详细文档,如下所示: image-20220214232409975看到这个后,嘴角疯狂上扬,在main.ts中的全局管道总开启了这个配置项,代码如下所示:async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ forbidUnknownValues: true })); await app.listen(3000); bootstrap();本以为万事大吉了,执行结果却不尽人意🌚 image-20220214233144828问题解决此时的我,陷入了沉思,按照描述应该是这个参数才对啊。沉思间,我看到了whitelist与forbidNonWhitelisted字段。whitelist 如果设置为true,验证器将剥离任何不使用任何装饰器的属性的验证对象。forbidNonWhitelisted 如果设置为true,则验证程序将抛出异常,而不是剥离非白名单属性。dto中未声明的字段一定是没有装饰器的,满足了whitelist字段,白名单的属性验证不通过时,让验证器抛出异常,正好满足了forbidNonWhitelisted属性,这样应该就成了吧,代码如下所示:async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) await app.listen(3000); bootstrap();继续使用postman进行调用接口进行测试,完美实现了我们想要的效果。 image-20220214234129804示例代码本文中所列举的完整代码请移步:main.ts[2]AppDto.ts[3]写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站[4],进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
模块层这一层是使用@Module() 装饰器的类,它提供了元数据,Nest 用它来组织应用程序结构。我们有了控制层和服务层后,它们还无法运行,因为它们缺少一个组织。实现代码接下来,我们在src目录下创建module文件夹,在其目录下创建AppModule.ts文件,代码如下所示:controllers 是一个数组类型的数据,我们把controller层的控制器在这里一一引入即可。providers 也是一个数组类型的数据,我们把service层的服务在这里一一引入即可。import { Module } from "@nestjs/common"; import { AppController } from "../controller/AppController"; import { AppService } from "../service/AppService"; @Module({ imports: [], controllers: [AppController], providers: [AppService] export class AppModule {}有关controllers与providers的详细介绍,请移步:Nest-@module配置入口文件接下来,我们在src目录下,创建main.ts文件,它的代码如下所示:导入AppModule,使用NestFactory来创建实例将3000端口设为本项目的监听import { NestFactory } from "@nestjs/core"; import { AppModule } from "./module/AppModule"; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); bootstrap();最后,我们运行package.json中的start:dev命令,在浏览器访问http://127.0.0.1:3000即可访问该项目。 image-20220114230042606验证控制层创建的控制器接下来,我们来验证下前面在AppController.ts中写的两个方法是否能正常运行。验证Get方法我们先来验证下get请求的访问情况,在浏览器访问http://127.0.0.1:3000/home/getTitle?id=12,客户端的界面如下所示: image-20220114230439191服务端同样也会输出客户端在地址栏所传的id,如下所示: image-20220114230550220验证Post方法我们需要使用postman来测试post方法能否正常访问,假设你已经安装好了postman,我们新建一个请求,写入地址http://127.0.0.1:3000/home/setTitle,访问结果如下所示: image-20220114230935445同样的,服务端也会收到我们在http body中所传的json数据,如下所示: image-20220114231123801DTO层(处理客户端参数)在前面的例子中,我们获取客户端的参数都是直接写在控制器内每个方法的参数中的,这样做引发的问题有:会降低代码的可读性,一大串参数写在方法里很不优雅。当很多方法都都需要传入相同参数时,要写很多重复代码,可维护性大大降低。参数的有效性验证需要写在控制器内的方法中,会产生冗余代码。DTO层的作用就是解决上述问题的,我们用class来处理客户端传入的参数。实现代码我们在src目录下创建DTO文件夹,在其目录下创建AppDto.ts文件,代码如下所示:export class AppDto { public id: string; public title: string; public name: string; export class GetNameDto extends AppDto { public type: string; }随后,我们在AppController.ts中的方法里使用即可,代码如下所示:import { AppDto, GetNameDto } from "../dto/AppDto"; @Controller("home") export class AppController { @Post("setTitle") setTitle(@Body() data: AppDto): { code: number; data: null | string; msg: string; // 其他代码省略 @Get("getName") getName(@Body() data: GetNameDto): { code: number; data: null | string; msg: string; // 其他代码省略 }完成上述操作后,我们就成功解决了1,2问题。由于参数的接收是采用类实现的,因此我们可以利用继承来避免冗余代码。使用管道验证参数的有效性接下来,我们使用管道来解决第3个问题,在nest官网中,它提供了8个开箱即用的内置管道,此处我们需要用它的ValidationPipe管道来验证参数。根据文档所述,在使用前我们需要先绑定管道,官网给出了两种方法:绑在 controller 或是其方法上,我们使用 @UsePipes() 装饰器并创建一个管道实例,并将其传递给 Joi 验证。在入口处将其设置为全局作用域的管道,用于整个应用程序中的每个路由处理器。此处我们使用全局作用域的管道,修改main.ts文件,代码如下所示:import { NestFactory } from "@nestjs/core"; import { AppModule } from "./module/AppModule"; import { ValidationPipe } from "@nestjs/common"; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe()); await app.listen(3000); bootstrap();有关管道的具体原理请移步:nest-绑定管道随后,我们即可在dto层中使用它的相关装饰器来校验参数了,AppDto.ts的部分代码如下所示:import { IsString, MinLength } from "class-validator"; export class AppDto { @MinLength(5) @IsString() public id!: string; @IsString() public title!: string; @IsString() public name!: string; export class GetNameDto extends AppDto { @IsString() public type!: string; }最后,我们使用postman来测试下是否生效,如下所示:传入了一个number类型的id没传name参数服务端返回了400错误,并告知了错误原因。 image-20220116221632391因为我们将参数的非空验证交给了装饰器,我们在dto类中,就需要用!:操作符来断言某个参数一定有值。我们从class-validator'包中引入了string类型的验证装饰器,它还能验证其它类型,感兴趣的开发者请移步:class-validator#usageVO层(返回给客户端的视图)通常情况下,我们返回给客户端的字段是固定的,在本文前面的controller层中,两个方法我们都返回了code、data、msg这三个字段,只是数据不同。那么我们就应该把它封装起来,将数据作为参数传入,这样就大大的提高了代码的可维护性,也就是我们所说的VO层。封装工具类我们在src目录下创建VO文件夹,在其目录下创建ResultVO.ts文件,代码如下所示:简单创建了一个类,添加了三个字段为每个字段写了get和set方法export class ResultVO<T> { private code!: number; private msg!: string; private data!: T | null; public getCode(): number { return this.code; public setCode(value: number): void { this.code = value; public getMsg(): string { return this.msg; public setMsg(value: string): void { this.msg = value; public getData(): T | null { return this.data; public setData(value: T | null): void { this.data = value; }随后,我们在src目录下创建utils文件夹,在其目录下创建VOUtils.ts文件,封装常用方法,便于其他层直接调用,代码如下所示:我们封装了success与error方法成功时,传入data进来失败时,传入code与msg告知客户端错误原因// 返回给调用者的视图结构 import { ResultVO } from "../VO/ResultVO"; export class VOUtils { public static success<T>(data?: T): ResultVO<T> { const resultVo = new ResultVO<T>(); resultVo.setCode(0); resultVo.setMsg("接口调用成功"); resultVo.setData(data || null); return resultVo; public static error(code: number, msg: string): ResultVO<null> { const resultVo = new ResultVO<null>(); resultVo.setCode(code); resultVo.setMsg(msg); return resultVo; }注意:success方法支持传入的参数是任意类型的,实际的业务需求中,data这一层会很复杂,你在实际使用时,可以根据具体的业务需求创建对应业务的vo类,然后对其进行实例化,为每个字段赋值。最后在调用success方法时将你实例化后的对象传入即可。在业务代码中使用随后,我们就可以在service层来使用我们创建好的工具类了,示例代码如下所示:import { VOUtils } from "../utils/VOUtils"; @Injectable() export class AppService implements AppInterface { // 其它代码省略 setTitle(): VOUtils { return VOUtils.success("标题设置成功"); }接口调用结果如下所示: image-20220116231739210类型层我们在写业务代码时,会碰到许许多多的Object类型的数据,通常情况下我们会给每个字段定义具体的类型,此时我们就需要将所有的类型放在一起,方便维护,此处我的做法是在src目录下创建type文件夹,将所有的类型定义都放在这个文件夹里,代码如下所示:创建了一个type文件夹type文件夹下创建了AppDataType.ts文件,用于存放所有类型export type book = { title: string; author: string; time: string; updateTime: string; export interface specialBook extends book { id: number; createTime: string; }注意:所有的类型定义我们都用type关键词来定义,使用的时候直接导入即可,当我们要继承某个类型时,就必须要使用interface关键词了。枚举层我们写业务代码时,肯定会遇到各种异常状况,当服务端发生异常时,我们就需要在VO层返回错误信息与状态码,如果我们直接将数据写在方法里,后期需要修改时,将会是一件很头痛的事情。那么,当我们把这些数据统一在枚举层进行定义,在业务代码中直接使用我们定义好的枚举,这个问题就迎刃而解了。我们在src目录下创建enum文件夹,在其文件夹下创建AppEnum.ts文件,代码如下所示:NOTFOUND 表示错误码NOTFOUND_DESCRIPTION 表示错误码的描述信息export enum AppEnum { NOTFOUND = -1, NOTFOUND_DESCRIPTION = "未找到相关人物" }随后,我们在业务代码使用即可,如下所示:@Injectable() export class AppService implements AppInterface { // 其它代码省略 getName(): VOUtils { return VOUtils.error(AppEnum.NOTFOUND, AppEnum.NOTFOUND_DESCRIPTION); }注意:typescript中的枚举不能像Java一样在定义的时候就设置相关的描述信息,所以此处只能选择曲线救国的方式在定义错误吗的时候多定义一个以__DESCRIPTION结尾的枚举。项目代码本文所使用的完整代码,请移步项目的GitHub仓库:nest-project写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站,进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言最近有个需求需要基于前端技术栈实现一套中间层API接口,用于处理由前端维护的一套JSON配置文件。经过一番查找后,最终选择了nest.js这个框架,由于它支持AOP编程,与SpringBoot的写法较为相似,可以将SpringBoot那套架构思想应用过来,这对于我这个全干工程师(懂亿点点Java)来说就非常友好了😁经过3天的学习与折腾,终于搭建了一套我比较满意的架构,本文就跟大家分享下我的架构方案,欢迎各位感兴趣的开发者阅读本文。写在前面本文所讲内容会涉及到TypeScript,如果你对它还不够理解,请先移步:TypeScript中文文档学习下,入个门🤓。本文完整项目代码移步:nest-project本文中所安装的依赖包要求你的node版本必须在14.16.0及以上。你可以使用node版本管理控制器n来管理你的node版本,你可以使用npm install -g n来安装它。安装完成后,你只需使用n 版本号即可安装并切换到对应版本的node了。macos下使用可能需要使用sudo n 版本号。例如:n 14.16.0。有关n的更多使用方法请移步:n-github环境搭建在nest官网中,它提供了三种搭建方式:使用CLI安装使用Git安装手动创建这三种安装方式都比较简单,感兴趣的开发者可自行查阅文档来了解学习。为了锻炼大家的动手能力,本文不采用上述方法来搭建项目,我们将从0开始使用yarn初始化一个空项目,然后安装nest的相关依赖包。注意:如果你已经搭建好了环境,请跳过此章节,前往下一个章节:项目架构。初始化一个空项目本文使用yarn来初始化项目,如果你没有安装的话需要先使用npm来安装下,命令如下:npm install --global yarn安装完成后,可以使用命令:yarn --version 来验证下是否安装成功,如果成功你会看到如下所示的输出: image-20220111215750509接下来,我们创建一个名为nest-project的空文件夹,在终端进入这个文件夹,使用命令:yarn init来初始化一个项目,如下所示,根据自己的需要填写即可,带括号的部分可以不填写保持默认,直接回车即可。 image-20220111222505312随后,我们打开这个项目,文件夹中只有一个package.json文件,内容如下所示:{ "name": "nest-project", "version": "1.0.0", "main": "index.js", // 这个可以删除,不需要这个字段 "author": "likai", "license": "MIT", "private": true }上述内容就是我们刚才在终端所选择的,因此你也可以自己创建一个空文件,创建这个json文件,写上对应的配置,达到相同的结果。安装nest依赖包我们打开刚才创建的package.json文件,添加如下所示的字段:{ "dependencies": { "@nestjs/common": "^8.1.1", "@nestjs/core": "^8.1.1", "@nestjs/platform-express": "^8.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.4.0" "devDependencies": { "@nestjs/cli": "^8.1.3", "@nestjs/schematics": "^8.0.4", "@types/express": "^4.17.13", "@types/node": "^16.11.1", "supertest": "^6.1.6", "ts-loader": "^9.2.6", "ts-node": "^10.3.0", "tsconfig-paths": "^3.11.0", "tslib": "^2.3.0", "typescript": "^4.4.4", "webpack": "5.0.0" }随后,我们打开终端,进入项目目录,执行yarn install 命令,成功后的界面如下所示: image-20220111225541175安装代码规范依赖包本文采用eslint和prettier来规范代码,对此不了解的开发者请移步我的另一篇文章:独立使用ESLint+Prettier对代码进行格式校验。接下来,我们打开前面所创建的package.json文件,在devDependencies对象中添加下述代码:{ "devDependencies": { "eslint": "^7.0.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.3.1", "prettier": "^2.2.1", "@typescript-eslint/eslint-plugin": "^4.18.0", "@typescript-eslint/parser": "^4.18.0", }添加完成后,执行yarn install就完成了依赖包的引入。添加启动命令安装完所有依赖后,接下来我们在package.json中添加6个运行脚本,用于项目的启动与打包构建,如下所示:prebuild 移除dist目录build 打包项目start 启动项目start:dev 启动项目(支持热更新)start:debug 以debugger模式启动项目(支持断点调试)start:prod 启动打包后的项目{ "scripts": { "prebuild": "rimraf dist", "build": "nest build", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main" }添加配置文件接下来,我们还需要在项目根目录添加nest、eslint、prettier等配置文件,如下所示:.editorconfig统一不同操作系统之间的代码格式相关问题的配置文件.eslintrc.js eslint的配置文件.gitignore git提交时需要忽略的文件.prettierrc.json prettier的配置文件nest-cli.json nest的配置文件tsconfig.json typescript的配置文件tsconfig.build.json 项目打包时ts文件的相关处理配置文件具体的文件内容,点击上方蓝色字体可直接跳转到GitHub中对应的文件。项目架构本章节将跟大家下分享我的项目架构,首先在项目根目录创建src文件夹,所有项目代码将存放在此目录下。本章节节对应的的完整项目代码移步:nest-project控制层这一层用于处理客户端传入的请求以及向客户端返回响应,所有的请求映射都会在这一层来实现。每个请求会对应一个控制器,一个控制器中可以有多个子方法用于处理同类型的不同操作。举例说明接下来,我们在src目录下创建controller文件夹,在其目录下新建一个AppController.ts文件。我们从一个例子入手:处理/home/setTitle的post请求,它的参数在http body中处理/home/getTitle的get请求,它的参数在请求url中实现代码翻阅官方文档后,我们就可以写出如下所示的代码:import { Body, Controller, Get, Query, Post } from "@nestjs/common"; @Controller("home") export class AppController { @Post("setTitle") setTitle(@Body() data: { id: number; title: string }): { code: number; data: null | string; msg: string; // 客户端传入的数据 console.log(data); // 返回给客户端的数据 return { code: 0, data: null, msg: "post方法调用成功" }; @Get("getTitle") getTitle(@Query("id") id: number): { code: number; data: string; msg: string; console.log("客户端传入的数据", id); return { code: 0, data: null, msg: "get方法调用成功" }; }我们来看下上述代码中各个装饰器的作用:@Controller 用于标识此文件是一个控制器,它接受一个参数,此处我写了home,代表所有/home的请求都会进到这里。@Post 用于处理post格式的请求,它也接受一个参数,此处我写了setTitle,代表/home/setTitle的post请求会进到这里。@Body用于获取http body中的数据@Query用于获取请求url中的数据在nest文档中,它提供的装饰器还有很多,可以应付各种开发场景,详情请移步:控制器- request。服务层服务层用于处理具体的业务逻辑,当我们收到客户端的请求后,取出参数编写具体的业务代码。举例说明接下来,我们在src目录下创建service文件夹,在其目录下新建一个AppService.ts文件。举个例子:写一个方法,根据id来做一些事情,做完后返回操作结果。实现代码查阅文档后,我们知道了需要使用@Injectable()来装饰这个类,代码如下所示: import { Injectable } from "@nestjs/common"; @Injectable() export class AppService { public setTitle(id: string): { code: number; data: null | string; msg: string; // 根据id做一些事情,此处省略 console.log(id); // 返回操作结果 return { code: 0, data: null, msg: "设置成功" }; }做完上述操作后,我们还需要改造下AppController,在constructor中引入我们刚才创建好的service,部分代码如下所示:export class TextAttributeController { constructor(private readonly appService: AppService) {} @Post("setTitle") setTitle(){ // 此处省略了较多代码,这里的重点是演示如何调用我们刚才写好的方法 return this.appService.setTitle(); }一个service类中会有很多方法,我们会根据控制层的映射建立与之对应的处理方法,这样就可以让控制层更专心的处理它的分内之事,提升代码可读性。接口层这一层用于声明每个service类中都有哪些方法,可以很大程度提升代码的可读性。如果没有这一层,当service中的方法越来越多时,代码也会特别长,想快速找到某个方法,将会变得很费时。举例说明接下来我们在src目录下创建interface文件夹,在其目录下新建一个AppInterface.ts文件。举个例子,我们需要在声明5个方法,分别如下所示:getTitlegetNamegetAgesetNamesetTitle实现代码在TypeScript中用interface关键字来声明一个接口,那么上述例子转换为代码后就如下所示:export interface AppInterface { getTitle(): string; getName(): string; getAge(): string; setName(): string; setTitle(): string; }做完上述操作后,我们还需要改造下service层的代码,让其实现这个接口,部分代码如下所示:@Injectable() export class AppService implements AppInterface { getAge(): string { return ""; getName(): string { return ""; // 其他方法省略 }在TypeScript中,我们使用implements关键字来实现一个接口。
前言上周六有个群友@我说Gitee的反馈模块新增了截图功能,我就去体验了下,发现他们用的就是我的插件😁,本文就跟大家分享下这个插件,欢迎各位感兴趣的开发者阅读本文。插件地址与实现原理本插件采用原生js实现,可以集成在任意一个web项目中,插件npm地址与GitHub地址请移步:js-screen-shot(npm)[1]js-screen-shot(GitHub)[2]插件的实现原理请移步:实现Web端自定义截屏[3]实现Web端自定义截屏(JS版)[4]在线体验本插件,可移步我的开源项目chat-system[5]进行体验,插件的运行效果视频请移步实现web端自定义截屏功能-效果视频[6]。Gitee产品经理的青睐月初的时候,Gitee的产品经理在掘金看到我的截图插件js-screen-shot[7]觉得还不错,他们最近在做这方面的功能,就打算将我的插件直接集成进去,跟我沟通了下版权相关的事情。 image-20211129225953184 沟通完成后,他问我要不要把插件在Gitee也放一份,可以帮我推荐下,我毫不犹豫的抱住了大腿,就把插件搬过去了,得到一波首页推荐😂 image-20211129230823603 imgGitee[8]的反馈模块需要登录后,点页面右侧的发送反馈图标。 gitee反馈影响体验的一些小问题上周二,从GitHub来了个网友,加了我微信,给我的插件提了两个issues,因为周内没时间处理这些问题,就计划周末统一处理下插件的issues。 image-20211129231616134整理有效的issues时间回到上周六早上,我打开GitHub瞅了一眼issues,许久不看居然已经有19条了。 image-20211129232250926 经过一番整理,去掉一些无用的和已经修改好了的,最终确定了4条:调用者可以在框选区域外绘制问题截图区域工具栏首次点击时删除裁剪框的8个可操作点修复框选完成后,鼠标点击其他位置截图工具栏跟着移动问题添加可选参数支持单击截全屏功能解决issues问题整理完成,接下来就是解决问题环节了。选区外绘制问题正常情况下,截图区域确立后,用户都会在裁剪框区域内进行绘制,所以我就没考虑这个边界情况🤥,插件用的人多了后,自然就有人发现了这个问题,我们拿gitee的反馈模块举例(gitee目前用的还是我的旧版插件,肯定存在这个问题),如下所示,我们绘制的4个红色方框都超出裁剪框了: image-20211129234154073 实现思路这个问题解决起来比较简单,裁剪框已经绘制好了,知道它的坐标信息,我们在进行绘制时,只需要判断当前鼠标位置是否超出裁剪框的坐标点区域即可。部分实现代码如下所示:// 获取裁剪框位置信息 const cutBoxPosition = this.data.getCutOutBoxPosition(); // 绘制中工具的起始x、y坐标不能小于裁剪框的起始坐标 // 绘制中工具的起始x、y坐标不能大于裁剪框的结束坐标 // 当前鼠标的x坐标不能小于裁剪框起始x坐标,不能大于裁剪框的结束坐标 // 当前鼠标的y坐标不能小于裁剪框起始y坐标,不能大于裁剪框的结束坐标 !getDrawBoundaryStatus(startX, startY, cutBoxPosition) || !getDrawBoundaryStatus(currentX, currentY, cutBoxPosition) return;getDrawBoundaryStatus函数实现如下所示:/** * 获取工具栏工具边界绘制状态 * @param startX x轴绘制起点 * @param startY y轴绘制起点 * @param cutBoxPosition 裁剪框位置信息 export function getDrawBoundaryStatus( startX: number, startY: number, cutBoxPosition: positionInfoType ): boolean { startX < cutBoxPosition.startX || startY < cutBoxPosition.startY || startX > cutBoxPosition.startX + cutBoxPosition.width || startY > cutBoxPosition.startY + cutBoxPosition.height // 无法绘制 return false; // 可以绘制 return true; }具体代码请移步提交记录: fix: 修复插件调用者可以在框选区域外绘制问题[9]
前言前几天有个人跟我反馈说,她fork了我右键菜单那个开源项目,一直无法打包成功。我寻思着应该不可能吧,当我尝试打包时,果然翻车了🤡。经过了一番调试后,终于找到了问题所在,本文就跟大家分享下这个问题从发现到解决的整个过程,欢迎各位感兴趣的开发者阅读本文。排查问题因为我电脑重装过几次系统,一些放在github上的项目我就没有备份,我把项目(https://github.com/likaia/vue-right-click-menu-next/)重新clone到本地,安装依赖项后运行了build命令,意想不到的事情发生了:它报错了😿ERROR Failed to compile with 4 errors 11:02:26 AM error in ./src/components/right-menu.vue Module parse failed: Unexpected token (1:0) File was processed with these loaders: * ./node_modules/eslint-loader/index.js You may need an additional loader to handle the result of these loaders. image-20210912110303981上述报错的意思是找不到处理vue文件的相关loader,我就纳闷了,这不可能啊,几个月前插件写好时还能打包的,现在咋就突然不能打包了呢。可能是node版本的问题难道是我node版本的问题?插件写好到现在代码一直没动过,唯一变化的就是我升级了node版本,降级node版本太麻烦,于是我安装了node版本管理工具n。因为我的系统是macos,我可以直接用brew来安装它,命令如下:brew install n如果你是windows系统,你可以通过npm包的形式来安装它,命令如下:npm install -g n安装完成后,我去找了下我写这个项目时所发布的node版本v14.14.0,我们用n工具来安装并切换它:n 14.14.0我们运行node --version命令看下是否成功。 image-20210912112948408一切准备就绪,我寻思着应该不会出现问题了吧😼,结果运行后,我傻眼了,仍然报着同样的错误🤧 image-20210912110303981node版本管理工具有挺多的,除了文中说的n还有nvm、npx,感兴趣的开发者可自行了解。发现猫腻(yarn.lock)当我一筹莫展发呆时,突然发现目录树中的yarn.lock变色了,看来是有改动了,我寻思着不可能啊,我没动package.json中的依赖项啊,怎么会发生变化呢? image-20210912115021573 重新创建个项目试试既然lock文件发生了变化,那我重新创建个项目试试,把相关依赖项拷过去再打包看看。我们继续使用Vue CLI作为插件搭建环境,对此不熟悉的开发者请移步我的另一篇文章:使用CLI开发一个Vue3的npm库vue create test-vue3-project项目创建完成后,我把相关文件拷贝了过去,修改了package.json中的build命令。{ "build": "vue-cli-service build --target lib --name vueRightMenuPlugin src/main.ts" }运行命令后,它居然打包成功了🌝 image-20210912120532953找到问题经过前面的一番折腾,创建了一个新的项目他就好了,那我比对下这俩项目有啥不同之处,那么问题就迎刃而解了。经过比对后,我发现了package.json中的不同之处:"dependencies": { "core-js": "^3.6.5", "vue": "^3.0.0" }"peerDependencies": { "core-js": "^3.6.5", "vue": "^3.0.0" }区别就在于,vue和core-js这两个包的位置,问题应该就出在这里了。我们来验证下吧,将dependencies中的那两个包放到peerDependencies中,重新install下,再build看下。不出意料,果然报错了。 image-20210912131448829 那么为啥我的项目之前能跑,现在却没法跑了,我想应该是因为之前改了后,我没有重新install的缘故吧🌝。解决问题那么,既然找到问题了,我们反过来,把右键菜单的peerDependencies下的两个包放到dependencies下,再看看问题能否得到解决。当我满怀信心的执行build命令后,结局却让我很失望。是的,他换了个错误🌚 image-20210912132222990看报错是类型无法自动推导,这就很怪异了。那么就只能尝试下我的三板斧了:重启软件重启电脑删除项目,重新clone,重新install依赖前两个尝试过后,发现并无卵用,只好用了最后一个方法。重新install后,执行了build命令,成功解决了这个问题。 image-20210912132919200为什么呢问题是解决了,那么为什么要那样做呢?接下来就带大家深入研究下dependencies和peerDependencies。dependenciesdependencies是package.json中的一个属性,里面放运行代码时所需的依赖,在install时这些包会被安装,打包项目时,这里面的包也会被打包进去。peerDependenciespeerDependencies也是package.json中的一个属性,这个单词翻译过来是对等依赖的意思,这里面的包在install时并不会安装,打包项目时,这里面的包也不会被打包进去。两者存在的问题如果将依赖包放在dependencies下,那么当别人在他的项目中引入你的插件时,会出现下述情况:他项目里没有引入你所需的依赖包,那么你插件所依赖的包会被安装他项目里引入了你所需的依赖包:版本号一致,那么你所需的依赖包不会被安装,插件将共用项目里的依赖包版本号不一致,那么你所需的依赖包就会被安装,项目里就存在了两套不同版本的依赖版本号一致那还好,万事大吉。版本号不一致时,你插件所依赖的那个包需要的功能与调用者项目里安装的那个版本的包并无区别,那么调用者的项目将变得臃肿起来,又多安装了一份依赖。如果将依赖包放在peerDependencies下,对插件开发者是不友好的,会出现下述问题:install的时候,所需的依赖不会安装,使用ide开发时会报错找不到相关依赖。image-20210912140550142build的时候,因为依赖未安装,导致无法打包(文章开头提到的报错)这么看的话,peerDependencies这个属性,好像没啥用了。当然存在即合理,如果大家有什么更好的看法,欢迎在评论区留言讨论。解决方案知道他们各自的优点和缺点后,我也就知道了如何解决这个问题。既然dependencies中的依赖包只要和调用者的版本号一致,就不需要重新安装依赖,那我们把它的版本号放开,给个范围,这样不就可以了😁在package.json中的版本号可以带下述符号:~波浪号,匹配最新补丁版本号,即版本号的第三个数字,例如~3.0.0就会匹配3.0.x版本,将在3.1.0停止^插入符号,匹配次要的版本号,即版本号的第二个数字,例如^3.0.0就会匹配任何3.x.x版本,将在4.0.0停止>、<、>=、<=比较运算符,匹配的就是这个区间的版本,例如>3.0.0 <= 3.1.4,就会匹配这个区间的版本号如果不带符号,那么它就是精确匹配。本文中,用的是^3.0.0,满足了我们插件的使用场景,因此不需要更改。写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。 如果本文帮到了你,那就点个“在看”吧。
查阅官方文档,更换执行命令我又看了一圈官方文档,说是让用node --loader ts-node/esm来执行 image-20210814152034219于是,我就换了这个命令,结果又换了新错误。 image-20210814152131588(node:65419) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time (Use `node --trace-warnings ...` to show where the warning was created) /Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:693 return new TSError(diagnosticText, diagnosticCodes); TSError: ⨯ Unable to compile TypeScript: handle-themes-file/lib/HandleThemes.ts:1:25 - error TS2307: Cannot find module 'fs' or its corresponding type declarations. 1 import { readdir } from "fs"; handle-themes-file/lib/HandleThemes.ts:5:20 - error TS7006: Parameter 'errStatus' implicitly has an 'any' type. 5 readdir(path, (errStatus, fileList) => { ~~~~~~~~~ handle-themes-file/lib/HandleThemes.ts:5:31 - error TS7006: Parameter 'fileList' implicitly has an 'any' type. 5 readdir(path, (errStatus, fileList) => { ~~~~~~~~ at createTSError (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:693:12) at reportTSError (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:697:19) at getOutput (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:884:36) at Object.compile (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:1186:30) at /Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/esm.ts:146:38 at Generator.next (<anonymous>) at /Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/dist/esm.js:8:71 at new Promise (<anonymous>) at __awaiter (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/dist/esm.js:4:12) at transformSource (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/dist/esm.js:88:16) { diagnosticText: "\x1B[96mhandle-themes-file/lib/HandleThemes.ts\x1B[0m:\x1B[93m1\x1B[0m:\x1B[93m25\x1B[0m - \x1B[91merror\x1B[0m\x1B[90m TS2307: \x1B[0mCannot find module 'fs' or its corresponding type declarations.\n" + '\n' + '\x1B[7m1\x1B[0m import { readdir } from "fs";\n' + '\x1B[7m \x1B[0m \x1B[91m ~~~~\x1B[0m\n' + "\x1B[96mhandle-themes-file/lib/HandleThemes.ts\x1B[0m:\x1B[93m5\x1B[0m:\x1B[93m20\x1B[0m - \x1B[91merror\x1B[0m\x1B[90m TS7006: \x1B[0mParameter 'errStatus' implicitly has an 'any' type.\n" + '\n' + '\x1B[7m5\x1B[0m readdir(path, (errStatus, fileList) => {\n' + '\x1B[7m \x1B[0m \x1B[91m ~~~~~~~~~\x1B[0m\n' + "\x1B[96mhandle-themes-file/lib/HandleThemes.ts\x1B[0m:\x1B[93m5\x1B[0m:\x1B[93m31\x1B[0m - \x1B[91merror\x1B[0m\x1B[90m TS7006: \x1B[0mParameter 'fileList' implicitly has an 'any' type.\n" + '\n' + '\x1B[7m5\x1B[0m readdir(path, (errStatus, fileList) => {\n' + '\x1B[7m \x1B[0m \x1B[91m ~~~~~~~~\x1B[0m\n', diagnosticCodes: [ 2307, 7006, 7006 ] }正确的解决方案折腾到这里,我已经用尽自己所能去找解决方案了,仍然没解决,只好去寻求网友的帮助,最后在@皮的很的帮助下,解决了这个问题。在他的帮助下,我才知道,原来要改tsconfig.json的配置才行😂。 image-20210814154002027 image-20210814153733411 要改的地方有2处,如下所示:{ "compilerOptions": { "module": "CommonJS", "types": [ "node" }做完上述配置后,我们把刚才在package.json修改的项目类型删掉,以及在导入时添加的js后缀也一起删掉。最后在终端执行ts-node handle-themes-file/main.ts,成功执行。 image-20210814154507894添加运行变量每次都要进入终端,敲一边命令才能执行ts文件,这太麻烦了,我希望的是可以在编辑器中点一下就能运行当前可视区域的ts文件。在WebStorm中是支持这个操作的,只需简单的配置即可,步骤如下:在package.json中配置一条脚本运行命令{ "ts-node": "ts-node" }打开Run/Debug Configurations面板 image-20210814155153643在弹出的面板中,添加一条执行命令。 image-20210814155236491 image-20210814155306162填写命令名称、执行脚本、环境变量,最后点OK即可完成配置。 image-20210814155508125配置完成后,我们就可以通过点击工具栏的 运行图标 来运行了。 image-20210814155714093 如果你没玩过webstorm,可以移步我的另一篇文章:合理使用WebStorm-环境配置篇,亲自上手体验一波。项目地址本文创建的项目,GitHub地址为:ts-node-utils项目中还加入了其他的一些规范代码的东西,如果你对此感兴趣,请移步:独立使用ESLint+Prettier对代码进行格式校验使用commitizen实现按团队规范提交代码写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站,进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言前几天遇到一个批量处理文件的需求,需要用node来实现,由于第一次接触它,没啥经验,又想写TS,于是就搭建了这么一套环境,期间也踩了挺多坑。本文就跟大家分享下我的实现过程,欢迎各位感兴趣的开发者阅读本文。环境搭建首先我们创建一个空项目,在项目根目录创建package.json文件。在文件中填写项目的基础信息,如下所示:{ "name": "ts-node-utils", "version": "1.0.0", "description": "一些用ts写的可执行node脚本", "private": false, "license": "MIT" }上述代码中,各个字段的含义:name 项目名version 版本号description 项目的描述private 项目是否为私有状态license 项目所采用的开源协议按照自己的实际情况填写即可。注意:你也可以使用yarn或者npm来初始化一个项目,在初始化过程中会提示你填写上述信息,命令为: yarn init | npm init。安装依赖开源社区中有一个名为ts-node的库,它可以运行时解析ts,执行node的API,读完它的文档后,我们知道了在项目中安装它的方法,如下所示:npm install -D typescript | yarn add typescript -D npm install -D ts-node | yarn add ts-node -D npm install -D tslib @types/node | yarn add tslib @types/node -D上述命令中,我们安装了typescript,ts-node,tslib,@types/node这四个包,上述代码中的|是或者的意思,提供了npm的安装方法和yarn的安装方法,根据自己的实际需求执行对应的命令即可。创建TS配置文件在项目根目录创建tsconfig.json文件,具体的配置请移步tsconfig配置,我的配置文件如下所示:{ "compilerOptions": { "target": "esnext", "module": "esnext", "strict": true, "jsx": "preserve", "importHelpers": true, "moduleResolution": "node", "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "sourceMap": true, "baseUrl": ".", "outDir": "./output", "types": [ "webpack-env" "declaration": true,// 是否生成声明文件 "declarationDir": "dist/type",// 声明文件打包的位置 "lib": [ "esnext", "dom", "dom.iterable", "scripthost" "exclude": [ "node_modules" }编写测试代码接下来,我们引入几个node的api来测试下上面的配置,看看能否正常运行。如下所示,我在项目根目录创建了handle-themes-file文件夹,并在文件夹内部创建了lib文件夹和main.ts文件。我们在lib文件夹下创建HandleThemes.ts文件,在这里编写一个获取文件夹下所有文件的方法,代码如下所示:import { readdir } from "fs"; export default class HandleThemes { public getFolderFiles(path: string): void { readdir(path, (errStatus, fileList) => { if (errStatus !== null) { console.log("文件读取失败, 错误原因: ", errStatus); return; console.log("文件读取成功", fileList); }最后,我们在main.ts下导入HandleThemes.ts,实例化后,调用getFolderFiles方法,如下所示:import HandleThemes from "./lib/HandleThemes"; const handles = new HandleThemes(); handles.getFolderFiles("/Users/likai/Desktop/测试文件夹");运行报错在ts-node的文档中,我们知道了在终端/命令行进入我们的项目根目录,执行ts-node xxx.ts就能执行了,此处我们运行的文件是main.ts文件,那么要执行的命令就为:ts-node handle-themes-file/main.ts然而,事情并没我们想象的那么顺利,命令执行后,会看到如下所示的报错: image-20210814145833539(node:65039) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. (Use `node --trace-warnings ...` to show where the warning was created) /Users/likai/Documents/WebProject/ts-node-utils/handle-themes-file/main.ts:1 import HandleThemes from "./lib/HandleThemes"; ^^^^^^ SyntaxError: Cannot use import statement outside a module at wrapSafe (internal/modules/cjs/loader.js:979:16) at Module._compile (internal/modules/cjs/loader.js:1027:27) at Module.m._compile (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:1311:23) at Module._extensions..js (internal/modules/cjs/loader.js:1092:10) at Object.require.extensions.<computed> [as .ts] (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/index.ts:1314:12) at Module.load (internal/modules/cjs/loader.js:928:32) at Function.Module._load (internal/modules/cjs/loader.js:769:14) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12) at main (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/bin.ts:331:12) at Object.<anonymous> (/Users/likai/Documents/WebProject/ts-node-utils/node_modules/ts-node/src/bin.ts:482:3)修改项目类型声明看报错提示,让在package.json中添加一个type类型为module的字段,那么我们就声明下,如下所示:{ "type": "module" }当我再次运行时,它又换了新的报错。 image-20210814150542095TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /Users/likai/Documents/WebProject/ts-node-utils/handle-themes-file/main.ts at Loader.defaultGetFormat [as _getFormat] (internal/modules/esm/get_format.js:71:15) at Loader.getFormat (internal/modules/esm/loader.js:102:42) at Loader.getModuleJob (internal/modules/esm/loader.js:231:31) at async Loader.import (internal/modules/esm/loader.js:165:17) at async Object.loadESM (internal/process/esm_loader.js:68:5) { code: 'ERR_UNKNOWN_FILE_EXTENSION' }百度这个报错时,基本上就一篇文章抄来抄去的 image-20210814150819838这篇文章说是因为找不到导入的模块,需要在导入时添加文件的后缀名,且需要把ts后缀换成js,我跟着操作后,报错依然存在。
删除分支当我们将某个分支合并到dev后,此时这个分支就不需要了,需要将其删除。在webstorm中,我们只需在远程分支列表中找到这个分支,右键选择Delete即可 image-20210725003634683 提交代码当我们修复了一个bug,或者完成了一个模块的开发时,需要将代码提交到本地,然后再推送远程仓库,在webstorm中只需要点击Toolbar中的commit图标和push图标即可。如下所示: image-20210725000121578在弹出的窗口中,填写提交信息即可。 image-20210725000233787提交完成后,点击推送按钮即可将本次提交推送到远程仓库。 image-20210725000436434在弹出的窗口中点push即可。 image-20210725000529092注意:如果你看不到Toolbar,则需要在菜单栏: view - Appearance - ToolBar将其开启。除此之外,你还可以在菜单栏的Git子菜单中去提交/推送,或者按快捷键command K / command shift K。拉取代码当需要获取某个分支上同事修改的最新代码时,此时就需要进行pull操作,我们只需在webstorm菜单栏的git子菜单下选择pull即可。 image-20210725001609640暂存与取出当我们在某个分支上开发需求时,突然来一个加急需求需要你在别的分支改,此时你的更改又不适宜提交,那么就需要将当前更改暂存起来。我们只需在项目树上右键,选择Git - Stash Changes...即可将更改暂存,如下图所示: image-20210725002140382在弹出的窗口中填写保存信息。 image-20210725002254573紧急任务开发完成后,我们切回分支,在项目根目录右键,选择Git - Unstash Change...即可。 image-20210725002450616版本回退当我们提交了代码后,测试那边测出了很多问题,此时我们就会觉得本次提交无意义,需要将其撤销。我们只需在Git面板中,选中要回退的git版本,右键选择Reset Current Branch to Here...即可 image-20210725002931454在弹出的菜单中选择Mixed选项即可。 image-20210725003002381注意:如果你的提交已经推到了远程仓库,你想删除远程仓库的记录,在本地回退后还需要在终端执行git push --force命令进行强推。强推是危险命令,如果你回退的版本之后还有别的同事提交的代码,那么此命令将会删除别的同事提交的代码。合并部分提交记录当我们需要将某个分支的部分提交合并到dev分支时,我们需要用到git cherry-pick命令。在webstorm中,我们只需切换分支到dev,然后在Git面板中选中需要合并提交的分支,选择需要合并的记录,点击樱桃图标即可完成合并。如下所示,我们需要将AddMenu分支的两个提交合并到dev分支: image-20210725004742222 最后,我们切换到dev分支即可看到合并过来的两个提交,如下所示: image-20210725004916220写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言webstorm中集成了世界上最好用的git管理工具,它可以大大提升我们的工作效率,本文就跟大家分享下工作中几个常用操作,欢迎各位感兴趣的开发者阅读本文。Git管理面板我们通过webstorm左下角的Git来打开这套集成工具。 image-20210724172051880 打开后的界面如下所示:Local Changes 展示你当前已修改但未提交的文件Log: master 你当前所在的分支选中一个提交记录,最右侧会展示当前提交记录所修改的文件Local 本地的分支列表Remote 远程仓库的分支列表左侧区域展示的是所有分支列表右侧区域展示的是当前选中分支的提交记录 image-20210724172729171如果你看不到左下角的Git,可能是因为你隐藏了Tool Window Bars,在菜单栏View -Appearance - Tool Window Bars将其勾选即可。 image-20210724180744707如果你对webstorm不是很熟悉,请移步我的另一篇文章:合理使用WebStorm-环境配置篇。常用的操作接下来跟大家分享下,工作中一些常用的git操作,如何在这套内置工具上实现。创建分支当项目需求明确后,我们要做的第一件事就是创建一个新分支来做这个需求,在这套内置git工具中,我们只需在我们需要基于的分支上右键选择New Branch from Selected...即可。例如:我们想基于master分支创建一个新的分支 image-20210724201805387在弹出框中输入新的分支名,点CREATE即可,如下图所示,我们给新分支起名为AddMenu image-20210724202217151按照上述步骤操作即可完成一个新分支的创建。注意:在弹出框中默认是创建并选中当前创建的分支的,如果你只想创建不想选中,取消弹出框里面的Checkout branch选中即可。创建完车后,我们可能还需要将这个分支推到远程仓库,我们在创建好的分支上右键选择Push...即可。 image-20210724210234782拉取分支当我们想选中同事的分支,帮同事改bug时,则需要将这个分支拉到本地,在这套内置git工具中我们只需在Remote中找到这个分支,右键选择Checkout即可。例如,我们想选中github_page分支: image-20210724203856360选择后,你会看到如下图所示的提示。 image-20210724204040261 合并分支当我们将需求开发完成,测试通过后,就需要将分支合并到dev去了,在这套内置工具中,我们只需要切换分支到dev,然后再需要合并的分支上右键选择Merge into Current即可。 image-20210724234453128如果有冲突的文件,则需要解决下冲突,如下所示:选中一个冲突的文件序号1标注 使用当前所在分支(dev)的文件序号2标注 使用合并分支的文件序号3标注 比对两个版本的文件差异,解决冲突 image-20210724234718134如果你选择了序号3标注的按钮,将看到如下所示的界面:左侧为dev分支的代码,中间为最终结果区域,右侧为合并分支的代码序号1、2、3标注的地方为应用此处更改到最终结果区域X的意思是舍弃此处的更改 image-20210724235117407
git提交模版我们在使用git提交代码时,团队如果制定了提交规范,可能需要自己去写提交前缀,在webstorm中有一个名为Git Commit Template的插件,可以手动选择类型,自动帮我们补齐前缀。在插件商店中搜索安装即可。 image-20210720003808245我们随便改点项目中的代码,然后选择菜单栏的git - commit image-20210720004508661 默认是在项目左侧显示,我们把它改为弹窗形式显示 image-20210720004631719 点击模版图标,即可打开提交选项 image-20210720004809668按照自己更改的内容,按需选择填写即可 image-20210720004935379 填写完成,将会回到提交页面,自动填写我们刚才所选择的选项 image-20210720005051274Git提交记录维护项目时,发现bug,我们想快速知道这行代码是谁提交的,大部分开发者可能要去通过git log来查找。在webstorm中,有一个名为GitToolBox的插件,当我们鼠标选择某一行代码时,就能显示出这行代码的提交人和提交时间。在插件商店搜索安装 image-20210720005537135安装完成,重启编辑器 image-20210720005618211鼠标选中代码,这一行的末尾就会显示提交人、提交时间等信息 image-20210720005737054AI代码联想工具webstorm中还有一款名为Codota的插件,他可以在你写代码时,自动联想出你想输入的内容。在插件商店中搜索安装即可。 image-20210720010111488安装完成,重启编辑器,打开setting-Codota面板,将其启用 image-20210720010636730随便写点代码即可看到效果 image-20210720010451528文件忽略我们在项目中不想让把某个文件上传到git,通常情况下我们需要自己往.gitignore文件中去添加要忽略的文件,在webstorm中有一款名为.ignore的插件,可以通过右键不想上传的文件即可实现将其添加到配置文件中。在插件商店中搜索安装即可。 image-20210720011017473右键,添加到忽略文件 image-20210720011244740 最终效果完成上述配置后,webstorm已经算是脱胎换骨了,但是还是觉得编辑器周围显示的选项卡有点多,我选择把它隐藏起来。 image-20210720012629644最终界面如下所示 image-20210720012713110注意:四周的选项卡隐藏后,在mac系统上可以通过双击command键让其显示出来。windows系统则需要设置快捷键让其显示出来,我们打开srttings面板在keymap中搜索Tool Window Bars然后设置快捷键。 image-20210721222227391 image-20210721222402431 image-20210721222425419其他配置此处再列举一些项目上的配置。Eslint的配置有关Eslint的配置请移步我的另一篇文章:配置编辑器构建项目索引当你在写代码时,发现vue的一些内置指令、elementUI的一些组件无代码提示时,就需要构建下项目索引了,操作方法如下:在node_modules文件夹上右键,在弹出的选项中选择Mark Directory as -Not Excluded即可 image-20210721220710616一些常用的快捷键选中当前行代码:command shift ⬅️/command shift ➡️移动当前行代码:command ⬆️/ commind ⬇️提交代码到git本地:command Kpush代码到git远程仓库: comnand shift Kshift 按两次,随处搜索,搜索文件、功能、代码很方便command + f 当前页搜索command + shift + f 全局搜索字段command + r 替换当前文档command + shift + r 全局替换字段command + option + l 格式化代码shift + f6 使文件、标签、变量名重命名f2, shift + f2 切换到上\下一个突出错误的位置shift + 回车 无论在什么位置,自动跳到下一行option + 回车 警告代码快速给出自动修正command + 左键点击 跳到代码调用位置command + delete 删除当前行command + d 复制新增一行一样的代码command + w 关闭当前文件选项卡command + / 注释行代码command + b 跳转到变量声明处command + shift + c 复制文件的路径command + shift + [ ] 选项卡快速切换,很有用command + shift + +/- 展开/折叠 当前选中的代码块将某一块代码提炼成一个方法用鼠标选中一块代码,按下:command+option+m即可自动将这部分代码提炼成一个方法。 image-20210721234032254配置备份点击下图所示图标(编辑器底部),点击登录自己账号即可完成同步 image-20210721232319259注意:如果你看不到这一栏,则需要在view - status Bar开启 image-20210721232611336禁止掉不用的插件在help菜单下禁用,如下图所示: image-20210721235131850在打开的面板中,选中你想禁用的插件点ok即可,如下图所示: image-20210721235319352申请许可证webstorm是付费的,官方有开放开源项目申请渠道,通过后可以免费使用1年,过期了可以接着申请续期,一般项目维护在 3 个月以上大概率可通过。申请地址:开源项目许可证写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言使用webstorm做为前端开发工具已经3年多时间了,抽空来记录下我常用的一些插件和配置,欢迎各位感兴趣的开发者阅读本文。环境配置首先,我们打开webstorm官网根据自己的系统下载对应的安装包。 image-20210719225511397 安装软件打开我们下载好的安装包,按照下图所示步骤进行安装。 image-20210719225838867 选择安装路径 image-20210719225951563 选择要安装的版本以及默认文件关联 image-20210719230156845 开始安装 image-20210719230229295安装中... image-20210719230306253 安装完成,重启电脑 image-20210719230732445启动软件安装完成后,双击桌面图标来启动它。 image-20210719232504013发送崩溃信息日志等到jet帮助他们改进产品,可以按照自己的需求选,此处选择发送。 image-20210719233201267选择免费试用,填写自己的邮箱即可 image-20210719233532472配置软件在软件启动界面,打开你的项目。 image-20210719234543701 打开项目中任意一个文件,这个界面看起来可能有点丑,后面我们会让他脱胎换骨 image-20210719234951634修改字体与行高依次选择菜单栏的File - Settings打开软件的设置面板。 image-20210719235316208 按照下图所示修改字体、大小、行高、开启连字符 image-20210719235546600 常用插件接下来,我们安装几个插件,让webstorm脱胎换骨。主题插件首先要安装的是主题插件Material Theme UI,打开软件的设置面板找到,Plugins,搜索这个插件 image-20210720000136770安装中... image-20210720000226973 安装成功,重启webstorm image-20210720000309157 安装图标插件安装完主题插件后,界面稍微好看了那么一点,但是图标还是默认的,很是不搭配,我们继续在Plugins中搜索Atom Material Icons image-20210720000824116 安装中... image-20210720000845996安装成功,应用更改,手动重启webstorm。 image-20210720000941830更换主题安装完主题插件和图标插件后,我们还需要在Settings面板中切换主题 image-20210720001708274在打开的面板中,在Theme选项那里选择你喜欢的主题,此处选择Atom One Dark (Material) image-20210720001959996 在Editor - Font面板中修改主题字体 image-20210720002152088 image-20210720002314482配置完成后的效果 image-20210720002437306 翻译插件英语不是很好的开发者,为变量起名时,遇到词穷的情况时,大多数情况会打开翻译网站翻译过后再粘贴过来,webstorm有一款名为Translation的插件,可以做到选中中文直接右键翻译并替换。我们在插件商店中搜索安装即可 image-20210720002918264 安装完成后,在编辑器中输入中文,右键即可翻译,如下所示: image-20210720003320120 image-20210720003336242
前言kodbox是一款云存储程序,支持跨平台访问,可以用它来构建一套网盘存储系统,本文就跟大家分享下整个搭建过程,欢迎各位感兴趣的开发者阅读本文。环境搭建我们先去可道云官网下载服务端安装包,如下所示: image-20210718225825408下载成功后,我们将其解压出来,打开解压出来的文件夹如下所示: image-20210718230707638 这是一个PHP项目,因此我们需要搭建一套PHP运行环境。PHP运行环境如果你的设备已经安装了PHP环境,这一步可以跳过。因为我对PHP不是很熟悉,本文直接使用集成工具MxSrvs,来构建这套环境,软件下载成功后,直接安装即可,安装完成后的界面如下所示: image-20210718234431362序号1标注的地方默认是没有安装的,安装可道云时我们需要选择redis做缓存,因此需要安装下。在扩展中点击redis即可 image-20210718235952605环境配置我们装好必要的环境后,接下来我们来看下具体的配置,打开MxSrvs的配置编辑菜单,如下所示 : image-20210719000650960 配置nginx软件集成的nginx,默认端口号是80,由于这个端口号已经被占用了,因此我么需要修改下端口号。 image-20210719000959555 点击上图中的序号1标注即可打开nginx的配置,我们找到listen:80;将80改为你想改的端口,此处改为82。 image-20210719001406559 紧接着,我们添加一个路径映射,指定一个端口号用于访问我们的下载好的php项目,如下图所示,添加一个虚拟主机。 image-20210719001624282 image-20210719002707907主机名称为此配置的名字文件名称为要访问的php文件名称项目位置为我们刚才下载的php项目位置添加完成后,默认生成的配置不是我们需要的,我们需要将其修改下:listen为访问端口号server_name 为我们刚才填写的root 为项目路径,我们需要指向项目的根目录server { listen 83; server_name kodbox; root /Users/likai/Documents/kodbox.1.21; #access_log /Applications/MxSrvs/logs/kodbox.log; include vhosts/_nginx.vhost.fpm; }上述配置是http访问,我还需要https访问,因此还需要再添加一个虚拟主机,配置如下所示:ssl_certificate 为你的ssl证书文件所在路径(需要crt格式的证书)ssl_certificate_key 为你的ssl证书key文件所在路径server { listen 84 ssl; server_name kodbox-https; ssl_certificate /Users/likai/Documents/nginx-ssl-home.kaisir.cn/1_home.kaisir.cn_bundle.crt; ssl_certificate_key /Users/likai/Documents/nginx-ssl-home.kaisir.cn/2_home.kaisir.cn.key; ssl_session_cache shared:SSL:1m; ssl_session_timeout 5m; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; root /Users/likai/Documents/kodbox.1.21; #access_log /Applications/MxSrvs/logs/kodbox-https.log; include vhosts/_nginx.vhost.fpm; }配置PHP软件集成的PHP服务,包含了phpMyAdmin,其默认端口是80,我们需要将其改成自己需要的端口号,此处改为81。打开/Applications/MxSrvs/bin/nginx/conf/vhosts/localhost.vhost进行修改# phpMyAdmin server { listen 81; server_name pma.mxss.com; root /Applications/MxSrvs/www/_phpmyadmin; #access_log /Applications/MxSrvs/logs/phpmyadmin.log; include vhosts/_nginx.vhost.fpm; # webgrind server { listen 81; server_name wg.mxss.com; root /Applications/MxSrvs/www/_webgrind; #access_log /Applications/MxSrvs/logs/webgrind.log; include vhosts/_nginx.vhost.fpm; # beanstalk-console server { listen 81; server_name bs.mxss.com; root /Applications/MxSrvs/www/_beanstalk/public; #access_log /Applications/MxSrvs/logs/beanstalk.log; include vhosts/_nginx.vhost.fpm; }配置mysql如果你的设备没有安装mysql,可以选择软件集成的mysql服务,默认端口号为3306,如果不冲突则无需做过多配置。如果冲突的话,则需要改端口号,如下所示,将port所对应的值改为你需要的即可。 image-20210719005317545配置redis由于我的设备上已经有了redis,默认端口号6379被占用了,因此我需要修改下端口号,如下所示,我将端口号改为了63790 image-20210719005617317随后,我们需要修改下php的配置文件在其末尾添加:extension=redis.sophp中使用redis需要安装php-redis插件(MxSrvs默认已经为我们安装了),这一步的目的就是为了让php可以识别到这个插件启动项目做完上述配置后,我们就可以启动项目了,我们打开MxSrvs,切换到“程序控制”菜单下,如下所示,根据自己的需要启动对应的服务即可。 image-20210719010859001启动成功后,通过浏览器访问83端口,出现如下所示的界面就配置成功了,点击下一步继续进行初始化即可。修改可道云端口号如果你修改了mysql的端口号或者redis的端口号,初始化项目时可能会报错../app/autoload.php[2];Redis->flushAll0; NOAUTH Authentication required.,这是因为它使用的是默认的redis,还需要在可道云的配置文件中进行对应的修改。我刚才还修改了redis的端口号,对应的也需要修改下可道云的端口号。打开kodbox.1.21/config/setting_user.php文件:DB_PORT 为你的mysql端口号DB_USER 为你的mysql登陆用户名DB_PWD 为你的mysql登陆密码$config['cache']['redis']['port'] = '63790'; 为你的redis端口号(如果你选择了redis作为缓存这一项就会出现)<?php $config['database'] = array ( 'DB_TYPE' => 'mysqli', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => 3306, 'DB_USER' => 'root', 'DB_PWD' => 'xxxx', 'DB_NAME' => 'kodbox', 'DB_SQL_LOG' => true, 'DB_FIELDS_CACHE' => true, 'DB_SQL_BUILD_CACHE' => false, $config['cache']['sessionType'] = 'file'; $config['cache']['cacheType'] = 'file'; $config['cache']['sessionType'] = 'redis'; $config['cache']['cacheType'] = 'redis'; $config['cache']['redis']['host'] = '127.0.0.1'; $config['cache']['redis']['port'] = '63790';实现效果完成上述配置后,刷新浏览器即可成功进入可道云主界面,如下所示: image-20210719011845567下载客户端可道云的客户端有android、ios、web、mac、windows,在其官网的下载界面即可下载对应的客户端。 image-20210719012216783写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言我们在使用Typora进行创作时,文章中的图片可以选择保存到本地或者上传到第三方服务方的图床。如果图片保存到本地,当我们需要在互联网和别人分享自己创作的内容时,图片是无法显示的,而第三方图床基本上都是收费的。本文就将跟大家分享下如何搭建一个属于自己的图床,欢迎各位感兴趣的开发者阅读本文。环境搭建在typora的偏好配置中,我们切换到图像一栏,如下所示: image-20210717193829888图中序号1位置,可以选择插入图片时的行为,点开后我们选择上传图片选项图中序号2位置,可以选择上传图片时用哪个图床客户端,点开后我们选择uPic选项安装图床客户端进入uPic项目的GitHub主页,在Releases页面下载安装包即可。 image-20210717200041404下载完成,解压后,将其拖拽到 应用程序 文件夹中。 image-20210717200357604配置客户端打开应用程序后,会在菜单栏出现一个图标,点击后在出现的选项中,点击“偏好设置”,如下所示:在打开的界面中,点击左下角的加号,在弹出的选项中点击自定义,如下图所示: image-20210717201448420选择自定义后,会出现如下所示的界面: image-20210717202944353上传资源所需配置我们先来降下前4个标注的作用:序号1标注为上传服务的接口地址序号2标注为接口的请求方式序号3标注,接口解析文件流时的字段名序号4标注为调用上传接口时所需的其他字段,界面如下所示: image-20210717203729412注意:我们需要增加一个Header字段,键名为:Content-Type,值为:multipart/form-data; charset=utf-8;。如果不添加,你的接口则会报错。body字段则是你调用上传接口时,所需的其它额外参数。获取资源所需配置接下来,我们继续看下其他标注的作用:标注5的值为上传成功后,接口所返回的文件路径地址。例如返回{path:"/uploads/20199afrj.png"},我们需要取出path的值,这里就需要写["path"],层级深的话则需要继续向数组中追加元素,详情请移步:URL 获取规则标注6为获取到上传的文件后,需要进行拼接的域名前缀配置完成后,我们可以点击验证来看下服务是否正常,如果正常你会看到如下所示的提示: image-20210717212425413最后,点击标注7的保存,我们的配置就完成了。上传服务上传服务可以使用任何一门后端语言来编写,只要遵循文件上传规范即可,由于后端语言我只会Java,本文就以Java+SpringBoot框架为例,写一段示例代码。控制层我们先来看下controller层的代码,如下所示:import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import com.lk.service.FileUploadService; import javax.annotation.Resource; import java.io.IOException; import java.util.HashMap; import java.util.Map; import lombok.extern.slf4j.Slf4j; @Slf4j @Controller // 允许跨域访问 @CrossOrigin() @RequestMapping("/uploads") public class FileUploadController { // 注入文件上传服务 @Resource(name = "FileUploadServiceImpl") private FileUploadService fileUploadService; @PostMapping("/singleFileUploadToPath") @ResponseBody public Map<String, Object> singleFileUploadToPath(@RequestParam("file") MultipartFile file, @RequestParam("path") String path) throws IOException { Map<String, Object> result = new HashMap<>(16); // 调用单文件上传接口 return fileUploadService.singleFileUpload(file, path); }上述代码中,我们接受两个参数:file:上传过来的文件流path:上传路径名接口层接下来,我们来看下service层的代码,如下所示:import org.springframework.web.multipart.MultipartFile; // 文件上传接口 public interface FileUploadService { Map<String, Object> singleFileUpload(MultipartFile file, String path) throws IOException; }实现层最后,我们来看下serviceimpl层的代码,如下所示:import java.util.Map; import com.lk.service.FileUploadService; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.HashMap; import java.io.File; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import com.lk.utils.UUIDUtil; import com.lk.utils.DateUtil; import com.lk.utils.FileUtil; @Slf4j @Service("FileUploadServiceImpl") public class FileUploadServiceImpl implements FileUploadService { // 从配置文件读取文件路径 @Value("${uploadFilePath}") private String fileBaseUrl; @Override public Map<String, Object> singleFileUpload(MultipartFile file, String path) throws IOException { return this.writeFile(file, path); private Map<String, Object> writeFile(MultipartFile file, String path) throws IOException { String fileType = file.getContentType(); long fileSize = file.getSize(); log.info("[文件类型] - [{}]", fileType); log.info("[文件名称] - [{}]", file.getOriginalFilename()); log.info("[文件大小] - [{}]", fileSize); // 文件名 String fileName = file.getOriginalFilename(); assert fileName != null; // 生成文件名 String finalFileName = UUIDUtil.getUUID()+fileName.substring(fileName.lastIndexOf(".")); // 向客户端推送文件信息 Map<String, Object> result = new HashMap<>(16); if (path.length() != 0) { String dayTime = DateUtil.getTimeForDay(); String writePath = fileBaseUrl + path + "/" + dayTime + "/"; // 路径不存在时,则创建 Boolean touchResult = FileUtil.touchFolder(writePath); if (!touchResult) { result.put("code", -2); result.put("msg", "服务器错误: 路径创建失败"); log.error("上传路径创建失败" + writePath); return result; // 则写入指定路径下 file.transferTo(new File(writePath + finalFileName)); result.put("path", dayTime + "/" + finalFileName); } else { // 将文件写入默认路径下 file.transferTo(new File(fileBaseUrl + finalFileName)); result.put("code", 0); result.put("data", "上传成功"); result.put("contentType", file.getContentType()); result.put("fileName", finalFileName); result.put("fileSize", file.getSize() + ""); return result; }图床客户端请求头配置上述代码中所列举的上传服务,出了file字段外,还需要传path字段,那么在图床客户端的配置就如下所示: image-20210717222238001实现效果 最后,我们来看下配置完成后的效果,如下所示: 1111.gif写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言webstorm 2021.1 版本更新后,一直使用的Material Theme UI主题开始收费了,如果不付费的话,文件树那里格外的小,看起来十分的难受。在v2ex上也看到有人讨论了这件事,在一个偶然的机会下,我找到了解决办法,本文就跟大家分享下这个方法,欢迎各位感兴趣的开发者阅读本文。Material Theme UI介绍这是jetbrains公司旗下所有软件(webstorm、idea、datagrap等)都可以使用的一款主题插件,它有10几种主题可以选择,可以让你的编辑器看起来十分美观。我用的是Atom One Dark Theme和Atom One Light Theme主题,分别对应的是夜间模式和白天模式,此处给大家展示下两种模式下的效果,如下图所示: image.png image.png上述效果图中除了使用了Material Theme UI插件外,我还安装了Atom Material lcons插件,这个是用于图标美化的。大家可能还看到了效果图中还有git提交记录的显示,这里我安装的是GitToolBox插件,大家需要的话可以去webstorm的plugins选项下搜索安装。解决方案在Material Theme UI插件官网上找了下它的历史版本,都尝试了下,发现5.7.0版本是最后一个免费版本,且支持最新的webstorm。下载安装包去它的版本记录中找到5.7.0或者直接点此处进行下载,如下图所示,直接点Download按钮即可 image.png安装插件下载成功后,你会得到一个名为Material_Theme-5.7.0.zip的压缩包,打开webstorm的plugins面板,如下图所示。按顺序点击,在弹出的选择文件窗口选择你刚下载的压缩包,安装成功后,重启webstorm即可。 image.png写在最后至此,文章就分享完毕了。我是神奇的程序员,一位前端开发工程师。如果你对我感兴趣,请移步我的个人网站(https://www.kaisir.cn/),进一步了解。公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
指纹登录这个函数接收2个参数:用户凭证、设备id,我们会通过这两个参数来调起客户端的指纹设备来验证身份,具体的实现代码如下:touchIDLogin: async function(certificate: string, touchId: string) { // 校验设备是否支持touchID const hasTouchID = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); if (hasTouchID) { // 更新登录凭证 this.touchIDLoginOptions.publicKey.challenge = this.base64ToArrayBuffer( certificate // 更新touchID this.touchIDLoginOptions.publicKey.allowCredentials[0].id = this.base64ToArrayBuffer( touchId // 开始校验指纹 await navigator.credentials.get(this.touchIDLoginOptions); // 调用指纹登录接口 this.$api.touchIdLogingAPI .touchIdLogin({ touchId: touchId, certificate: certificate .then((res: responseDataType) => { if (res.code == 0) { // 存储当前用户信息 localStorage.setItem("token", res.data.token); localStorage.setItem("refreshToken", res.data.refreshToken); localStorage.setItem("profilePicture", res.data.avatarSrc); localStorage.setItem("userID", res.data.userID); localStorage.setItem("username", res.data.username); const certificate = res.data.certificate; localStorage.setItem("certificate", certificate); // 跳转消息组件 this.$router.push({ name: "message" return; // 切回登录界面 this.isLoginStatus = loginStatusEnum.NOT_LOGGED_IN; alert(res.msg); }注意⚠️:注册新的指纹后,旧的Touch id就会失效,只能通过新的Touch ID来登录,否则系统无法调起指纹设备,会报错:认证出了点问题。整合现有登录逻辑完成上述步骤后,我们已经实现了整个指纹的注册、登录的逻辑,接下来我们来看看如何将其与现有登录进行整合。调用指纹注册当用户使用用户名、密码或者第三方平台授权登录成功后,我们就调用指纹注册函数,提示用户是否对本网站进行授权,实现代码如下:authLogin: function(state: string, code: string, platform: string) { this.$api.authLoginAPI .authorizeLogin({ state: state, code: code, platform: platform .then(async (res: responseDataType) => { if (res.code == 0) { // ... 授权登录成功,其他代码省略 ... // // 保存用户凭证,用于指纹登录 const certificate = res.data.certificate; localStorage.setItem("certificate", certificate); // 校验设备是否支持touchID const hasTouchID = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); if (hasTouchID) { // ... 其他代码省略 ... // // 获取Touch ID,检测用户是否已授权本网站指纹登录 this.$api.touchIdLogingAPI .getTouchID({ userId: userId .then(async (res: responseDataType) => { if (res.code !== 0) { // touchId不存在, 询问用户是否注册touchId await this.touchIDRegistered(username, userId, certificate); // 保存touchid localStorage.setItem("touchId", res.data); // 跳转消息组件 await this.$router.push({ name: "message" return; // 设备不支持touchID,直接跳转消息组件 await this.$router.push({ name: "message" return; // 登录失败,切回登录界面 this.isLoginStatus = loginStatusEnum.NOT_LOGGED_IN; alert(res.msg); }最终的效果如下所示:每次第三方平台授权登录时都会检测当前用户是否已授权本网站,如果已授权则将Touch ID保存至本地,用于通过指纹直接登录。调用指纹登录当登录页面加载完毕1s后,我们从用户本地取出用户凭证与Touch ID,如果存在则提示用户是否需要通过指纹来登录系统,具体代码如下所示:mounted() { const touchId = localStorage.getItem("touchId"); const certificate = localStorage.getItem("certificate"); // 如果touchId存在,则调用指纹登录 if (touchId && certificate) { // 提示用户是否需要touchId登录 setTimeout(() => { if (window.confirm("您已授权本网站通过指纹登录,是否立即登录?")) { this.touchIDLogin(certificate, touchId); }, 1000); }最终效果如下所示:项目地址本文代码的完整地址请移步:Login.vue在线体验地址:chat-system项目GitHub地址:chat-system-github写在最后最近打算换工作,有没有广州这边的公司可以内推我呀😁,我的微信:Baymax-kt公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言现在越来越多的笔记本电脑内置了指纹识别,用于快速从锁屏进入桌面,一些客户端的软件也支持通过指纹来认证用户身份。前几天我在想,既然客户端软件能调用指纹设备,web端应该也可以调用,经过一番折腾后,终于实现了这个功能,并应用在了我的开源项目中。本文就跟大家分享下我的实现思路以及过程,欢迎各位感兴趣的开发者阅读本文。实现思路浏览器提供了Web Authentication API, 我们可以利用这套API来调用用户的指纹设备来实现用户信息认证。注册指纹首先,我们需要拿到服务端返回的用户凭证,随后将用户凭证传给指纹设备,调起系统的指纹认证,认证通过后,回调函数会返回设备id与客户端信息,我们需要将这些信息保存在服务端,用于后面调用指纹设备来验证用户身份,从而实现登录。接下来,我们总结下注册指纹的过程,如下所示:用户使用其他方式在网站登录成功后,服务端返回用户凭证,将用户凭证保存到本地检测客户端是否存在指纹设备如果存在,将服务端返回的用户凭证与用户信息传递给指纹注册函数来创建指纹身份认证成功,回调函数返回设备id与客户端信息,将设备id保存到本地将设备id与客户端信息发送至服务端,将其存储到指定用户数据中。⚠️注意:注册指纹只能工作在使用 https 连接,或是使用 localhost的网站中。指纹认证用户在我们网站授权指纹登录后,会将用户凭证与设备id保存在本地,当用户进入我们网站时,会从本地拿到这两条数据,提示它是否需要通过指纹来登录系统,同意之后则将设备id与用户凭证传给指纹设备,调起系统的指纹认证,认证通过后,调用登录接口,获取用户信息。接下来,我们总结下指纹认证的过程,如下所示:从本地获取用户凭证与设备id检测客户端是否存在指纹设备如果存在,将用户凭证与设备id传给指纹认证函数进行校验身份认证成功,调用登录接口获取用户信息⚠️注意:指纹认证只能工作在使用 https 连接,或是使用 localhost的网站中。实现过程上一个章节,我们捋清了指纹登录的具体实现思路,接下来我们来看下具体的实现过程与代码。服务端实现首先,我们需要在服务端写3个接口:获取TouchID、注册TouchID、指纹登录获取TouchID这个接口用于判断登录用户是否已经在本网站注册了指纹,如果已经注册则返回TouchID到客户端,方便用户下次登录。controller层代码如下@ApiOperation(value = "获取TouchID", notes = "通过用户id获取指纹登录所需凭据") @CrossOrigin() @RequestMapping(value = "/getTouchID", method = RequestMethod.POST) public ResultVO<?> getTouchID(@ApiParam(name = "传入userId", required = true) @Valid @RequestBody GetTouchIdDto touchIdDto, @RequestHeader(value = "token") String token) { JSONObject result = userService.getTouchID(JwtUtil.getUserId(token)); if (result.getEnum(ResultEnum.class, "code").getCode() == 0) { // touchId获取成功 return ResultVOUtil.success(result.getString("touchId")); // 返回错误信息 return ResultVOUtil.error(result.getEnum(ResultEnum.class, "code").getCode(), result.getEnum(ResultEnum.class, "code").getMessage()); }接口具体实现代码如下// 获取TouchID @Override public JSONObject getTouchID(String userId) { JSONObject returnResult = new JSONObject(); // 根据当前用户id从数据库查询touchId User user = userMapper.getTouchId(userId); String touchId = user.getTouchId(); if (touchId != null) { // touchId存在 returnResult.put("code", ResultEnum.GET_TOUCHID_SUCCESS); returnResult.put("touchId", touchId); return returnResult; // touchId不存在 returnResult.put("code", ResultEnum.GET_TOUCHID_ERR); return returnResult; }注册TouchID这个接口用于接收客户端指纹设备返回的TouchID与客户端信息,将获取到的信息保存到数据库的指定用户。controller层代码如下@ApiOperation(value = "注册TouchID", notes = "保存客户端返回的touchid等信息") @CrossOrigin() @RequestMapping(value = "/registeredTouchID", method = RequestMethod.POST) public ResultVO<?> registeredTouchID(@ApiParam(name = "传入userId", required = true) @Valid @RequestBody SetTouchIdDto touchIdDto, @RequestHeader(value = "token") String token) { JSONObject result = userService.registeredTouchID(touchIdDto.getTouchId(), touchIdDto.getClientDataJson(), JwtUtil.getUserId(token)); if (result.getEnum(ResultEnum.class, "code").getCode() == 0) { // touchId获取成功 return ResultVOUtil.success(result.getString("data")); // 返回错误信息 return ResultVOUtil.error(result.getEnum(ResultEnum.class, "code").getCode(), result.getEnum(ResultEnum.class, "code").getMessage()); }接口具体实现代码如下// 注册TouchID @Override public JSONObject registeredTouchID(String touchId, String clientDataJson, String userId) { JSONObject result = new JSONObject(); User row = new User(); row.setTouchId(touchId); row.setClientDataJson(clientDataJson); row.setUserId(userId); // 根据userId更新touchId与客户端信息 int updateResult = userMapper.updateTouchId(row); if (updateResult>0) { result.put("code", ResultEnum.SET_TOUCHED_SUCCESS); result.put("data", "touch_id设置成功"); return result; result.put("code", ResultEnum.SET_TOUCHED_ERR); return result; }指纹登录这个接口接收客户端发送的用户凭证与touchId,随后将其和数据库中的数据进行校验,返回用户信息。controller层代码如下@ApiOperation(value = "指纹登录", notes = "通过touchId与用户凭证登录系统") @CrossOrigin() @RequestMapping(value = "/touchIdLogin", method = RequestMethod.POST) public ResultVO<?> touchIdLogin(@ApiParam(name = "传入Touch ID与用户凭证", required = true) @Valid @RequestBody TouchIDLoginDto touchIDLogin) { JSONObject result = userService.touchIdLogin(touchIDLogin.getTouchId(), touchIDLogin.getCertificate()); return LoginUtil.getLoginResult(result); }接口具体实现代码如下// 指纹登录 @Override public JSONObject touchIdLogin(String touchId, String certificate) { JSONObject returnResult = new JSONObject(); User row = new User(); row.setTouchId(touchId); row.setUuid(certificate); User user = userMapper.selectUserForTouchId(row); String userName = user.getUserName(); String userId = user.getUserId(); // 用户名为null则返回错误信息 if (userName == null) { // 指纹认证失败 returnResult.put("code", ResultEnum.TOUCHID_LOGIN_ERR); return returnResult; // 指纹认证成功,返回用户信息至客户端 // ... 此处代码省略,根据自己的需要返回用户信息即可 ...// returnResult.put("code", ResultEnum.LOGIN_SUCCESS); return returnResult; }前端实现前端部分,需要将现有的登录逻辑和指纹认证相结合,我们需要实现两个函数:指纹注册、指纹登录。指纹注册这个函数我们需要接收3个参数:用户名、用户id、用户凭证,我们需要这三个参数来调用指纹设备来生成指纹,具体的实现代码如下:touchIDRegistered: async function( userName: string, userId: string, certificate: string // 校验设备是否支持touchID const hasTouchID = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); hasTouchID && window.confirm("检测到您的设备支持指纹登录,是否启用?") // 更新注册凭证 this.touchIDOptions.publicKey.challenge = this.base64ToArrayBuffer( certificate // 更新用户名、用户id this.touchIDOptions.publicKey.user.name = userName; this.touchIDOptions.publicKey.user.displayName = userName; this.touchIDOptions.publicKey.user.id = this.base64ToArrayBuffer( userId // 调用指纹设备,创建指纹 const publicKeyCredential = await navigator.credentials.create( this.touchIDOptions if (publicKeyCredential && "rawId" in publicKeyCredential) { // 将rowId转为base64 const rawId = publicKeyCredential["rawId"]; const touchId = this.arrayBufferToBase64(rawId); const response = publicKeyCredential["response"]; // 获取客户端信息 const clientDataJSON = this.arrayBufferToString( response["clientDataJSON"] // 调用注册TouchID接口 this.$api.touchIdLogingAPI .registeredTouchID({ touchId: touchId, clientDataJson: clientDataJSON .then((res: responseDataType<string>) => { if (res.code === 0) { // 保存touchId用于指纹登录 localStorage.setItem("touchId", touchId); return; alert(res.msg); }上面函数中在创建指纹时,用到了一个对象,它是创建指纹必须要传的,它的定义以及每个参数的解释如下所示:const touchIDOptions = { publicKey: { rp: { name: "chat-system" }, // 网站信息 user: { name: "", // 用户名 id: "", // 用户id(ArrayBuffer) displayName: "" // 用户名 pubKeyCredParams: [ type: "public-key", alg: -7 // 接受的算法 challenge: "", // 凭证(touchIDOptions) authenticatorSelection: { authenticatorAttachment: "platform" }由于touchIDOptions中,有的参数需要ArrayBuffer类型,我们数据库保存的数据是base64格式的,因此我们需要实现base64与ArrayBuffer之间相互转换的函数,实现代码如下:base64ToArrayBuffer: function(base64: string) { const binaryString = window.atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); return bytes.buffer; arrayBufferToBase64: function(buffer: ArrayBuffer) { let binary = ""; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); return window.btoa(binary); }指纹认证通过后,会在回调函数中返回客户端信息,数据类型是ArrayBuffer,数据库需要的格式是string类型,因此我们需要实现ArrayBuffer转string的函数,实现代码如下:arrayBufferToString: function(buffer: ArrayBuffer) { let binary = ""; const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); return binary; }注意⚠️:用户凭证中不能包含 _ 和 **-**这两个字符,否则base64ToArrayBuffer函数将无法成功转换。
前言前几天我发布了一个web端自定义截图的插件,在使用过程中有开发者反馈这个插件无法在vue2项目中使用,于是,我就开始找问题,发现我的插件是基于Vue3的开发的,由于Vue3的插件和Vue2的插件完全不兼容,因此插件也就只能在Vue3项目中使用。经过一番考虑后,我决定用原生js来重构这个插件,让其不依赖任何库,这样它就能运行在任意一台支持js的设备上,本文就跟大家分享下我重构这个插件的过程,欢迎各位感兴趣的开发者阅读本文。写在前面本文不讲解插件的具体实现思路,对插件实现思路感兴趣的开发者请移步:实现Web端自定义截屏搭建开发环境我想使用ts、scss、eslint、prettier来提升插件的可维护性,又嫌麻烦,不想手动配置webpack环境,于是我决定使用Vue CLI来搭建插件开发环境。本文不细讲Vue CLI搭建插件开发环境的过程,对此感兴趣的开发者请移步:使用CLI开发一个Vue3的npm库。移除vue相关依赖我们搭建好插件的开发环境后,CLI默认会在package.json中添加Vue的相关包,我们的插件不会依赖于vue,因此我们把它删除即可。{ - "vue": "^3.0.0-0", - "vue-class-component": "^8.0.0-0" }创建DOM为了方便开发者使用dom,这里选择使用js动态来创建dom,最后将其挂载到body中,在vue3版本的截图插件中,我们可以使用vue组件来辅助我们,这里我们就要基于组件来使用js来创建对应的dom,为其绑定对应的事件。部分实现代码如下,完整代码请移步:CreateDom.tsimport toolbar from "@/lib/config/Toolbar"; import { toolbarType } from "@/lib/type/ComponentType"; import { toolClickEvent } from "@/lib/split-methods/ToolClickEvent"; import { setBrushSize } from "@/lib/common-methords/SetBrushSize"; import { selectColor } from "@/lib/common-methords/SelectColor"; import { getColor } from "@/lib/common-methords/GetColor"; export default class CreateDom { // 截图区域canvas容器 private readonly screenShortController: HTMLCanvasElement; // 截图工具栏容器 private readonly toolController: HTMLDivElement; // 绘制选项顶部ico容器 private readonly optionIcoController: HTMLDivElement; // 画笔绘制选项容器 private readonly optionController: HTMLDivElement; // 文字工具输入容器 private readonly textInputController: HTMLDivElement; // 截图工具栏图标 private readonly toolbar: Array<toolbarType>; constructor() { this.screenShortController = document.createElement("canvas"); this.toolController = document.createElement("div"); this.optionIcoController = document.createElement("div"); this.optionController = document.createElement("div"); this.textInputController = document.createElement("div"); // 为所有dom设置id this.setAllControllerId(); // 为画笔绘制选项角标设置class this.setOptionIcoClassName(); this.toolbar = toolbar; // 渲染工具栏 this.setToolBarIco(); // 渲染画笔相关选项 this.setBrushSelectPanel(); // 渲染文本输入 this.setTextInputPanel(); // 渲染页面 this.setDomToBody(); // 隐藏所有dom this.hiddenAllDom(); /** 其他代码省略 **/ }插件入口文件在开发vue插件时我们需要暴露一个install方法,由于此处我们不需要依赖vue,我们就无需暴露install方法,我的预想效果是:用户在使用我插件时,直接实例化插件就能正常运行。因此,我们默认暴露出一个class,无论是使用script标签引入插件,还是在其他js框架里使用import来引入插件,都只需要在使用时new一下即可。部分代码如下,完整代码请移步:main.tsimport CreateDom from "@/lib/main-entrance/CreateDom"; // 导入截图所需样式 import "@/assets/scss/screen-short.scss"; import InitData from "@/lib/main-entrance/InitData"; import { cutOutBoxBorder, drawCutOutBoxReturnType, movePositionType, positionInfoType, zoomCutOutBoxReturnType } from "@/lib/type/ComponentType"; import { drawMasking } from "@/lib/split-methods/DrawMasking"; import { fixedData, nonNegativeData } from "@/lib/common-methords/FixedData"; import { drawPencil, initPencil } from "@/lib/split-methods/DrawPencil"; import { drawText } from "@/lib/split-methods/DrawText"; import { drawRectangle } from "@/lib/split-methods/DrawRectangle"; import { drawCircle } from "@/lib/split-methods/DrawCircle"; import { drawLineArrow } from "@/lib/split-methods/DrawLineArrow"; import { drawMosaic } from "@/lib/split-methods/DrawMosaic"; import { drawCutOutBox } from "@/lib/split-methods/DrawCutOutBox"; import { zoomCutOutBoxPosition } from "@/lib/common-methords/ZoomCutOutBoxPosition"; import { saveBorderArrInfo } from "@/lib/common-methords/SaveBorderArrInfo"; import { calculateToolLocation } from "@/lib/split-methods/CalculateToolLocation"; export default class ScreenShort { // 当前实例的响应式data数据 private readonly data: InitData; // video容器用于存放屏幕MediaStream流 private readonly videoController: HTMLVideoElement; // 截图区域canvas容器 private readonly screenShortController: HTMLCanvasElement | null; // 截图工具栏dom private readonly toolController: HTMLDivElement | null; // 截图图片存放容器 private readonly screenShortImageController: HTMLCanvasElement; // 截图区域画布 private screenShortCanvas: CanvasRenderingContext2D | undefined; // 文本区域dom private readonly textInputController: HTMLDivElement | null; // 截图工具栏画笔选项dom private optionController: HTMLDivElement | null; private optionIcoController: HTMLDivElement | null; // 图形位置参数 private drawGraphPosition: positionInfoType = { startX: 0, startY: 0, width: 0, height: 0 // 临时图形位置参数 private tempGraphPosition: positionInfoType = { startX: 0, startY: 0, width: 0, height: 0 // 裁剪框边框节点坐标事件 private cutOutBoxBorderArr: Array<cutOutBoxBorder> = []; // 当前操作的边框节点 private borderOption: number | null = null; // 点击裁剪框时的鼠标坐标 private movePosition: movePositionType = { moveStartX: 0, moveStartY: 0 // 鼠标点击状态 private clickFlag = false; private fontSize = 17; // 最大可撤销次数 private maxUndoNum = 15; // 马赛克涂抹区域大小 private degreeOfBlur = 5; // 文本输入框位置 private textInputPosition: { mouseX: number; mouseY: number } = { mouseX: 0, mouseY: 0 constructor() { // 创建dom new CreateDom(); this.videoController = document.createElement("video"); this.videoController.autoplay = true; this.screenShortImageController = document.createElement("canvas"); // 实例化响应式data this.data = new InitData(); // 获取截图区域canvas容器 this.screenShortController = this.data.getScreenShortController() as HTMLCanvasElement | null; this.toolController = this.data.getToolController() as HTMLDivElement | null; this.textInputController = this.data.getTextInputController() as HTMLDivElement | null; this.optionController = this.data.getOptionController() as HTMLDivElement | null; this.optionIcoController = this.data.getOptionIcoController() as HTMLDivElement | null; this.load(); /** 其他代码省略 **/ }对外暴露default属性做完上述配置后我们的插件开发环境就搭建好了,我执行build命令打包插件后,在vue2项目中使用import形式正常运行,在使用script标签时引入时却报错了,于是我将暴露出来的screenShotPlugin变量打印出来后发现他还有个default属性,default属性才是我们插件暴露出来的东西。求助了下我朋友@_Dreams找到了解决方案,需要配置下webpack中的output.libraryExport属性,我们的插件是使用Vue CLI开发的,有关webpack的配置需要在需要在vue.config.js中进行配置,代码如下:module.exports = { // 自定义webpack配置 configureWebpack: { output: { // 对外暴露default属性 libraryExport: "default" }这一块的配置在Vue CLI文档中也有被提到,感兴趣的开发者请移步:build-targets.html#vue-vs-js-ts-entry-files使用webrtc截取整个屏幕插件一开始使用的是html2canvas来将dom转换为canvas的,因为他要遍历整个body中的dom,然后再转换成canvas,而且图片还不能跨域,如果页面中图片一多,它会变得非常慢。在上一篇文章的评论区中有位开发者 @名字什么的都不重要 建议我使用webrtc来替代html2canvas,于是我就看了下webrtc的相关文档,最终实现了截屏功能,它截取出来的东西更精确、性能更好,不存在卡顿问题也不存在css问题,而且它把选择权交给了用户,让用户决定来共享屏幕的那一部分内容。实现思路接下来就跟大家分享下我的实现思路:使用getDisplayMedia来捕获屏幕,得到MediaStream流将得到的MediaStream流输出到video标签中使用canvas将video标签中的内容绘制到canvas容器中有关getDisplayMedia的具体用法,请移步:使用屏幕捕获API实现代码接下来,我们来看下具体的实现代码,完整代码请移步:main.ts// 加载截图组件 private load() { // 设置截图区域canvas宽高 this.data.setScreenShortInfo(window.innerWidth, window.innerHeight); // 设置截图图片存放容器宽高 this.screenShortImageController.width = window.innerWidth; this.screenShortImageController.height = window.innerHeight; // 显示截图区域容器 this.data.showScreenShortPanel(); // 截取整个屏幕 this.screenShot(); // 开始捕捉屏幕 private startCapture = async () => { let captureStream = null; try { // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore // 捕获屏幕 captureStream = await navigator.mediaDevices.getDisplayMedia(); // 将MediaStream输出至video标签 this.videoController.srcObject = captureStream; } catch (err) { throw "浏览器不支持webrtc" + err; return captureStream; // 停止捕捉屏幕 private stopCapture = () => { const srcObject = this.videoController.srcObject; if (srcObject && "getTracks" in srcObject) { const tracks = srcObject.getTracks(); tracks.forEach(track => track.stop()); this.videoController.srcObject = null; // 截屏 private screenShot = () => { // 开始捕捉屏幕 this.startCapture().then(() => { setTimeout(() => { // 获取截图区域canvas容器画布 const context = this.screenShortController?.getContext("2d"); if (context == null || this.screenShortController == null) return; // 赋值截图区域canvas画布 this.screenShortCanvas = context; // 绘制蒙层 drawMasking(context); // 将获取到的屏幕截图绘制到图片容器里 this.screenShortImageController .getContext("2d") ?.drawImage( this.videoController, this.screenShortImageController?.width, this.screenShortImageController?.height // 添加监听 this.screenShortController?.addEventListener( "mousedown", this.mouseDownEvent this.screenShortController?.addEventListener( "mousemove", this.mouseMoveEvent this.screenShortController?.addEventListener( "mouseup", this.mouseUpEvent // 停止捕捉屏幕 this.stopCapture(); }, 300); };插件地址至此,插件的实现过程就分享完毕了。插件在线体验地址:chat-system插件GitHub仓库地址:screen-shot开源项目地址:chat-system-github写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言当客户在使用我们的产品过程中,遇到问题需要向我们反馈时,如果用纯文字的形式描述,我们很难懂客户的意思,要是能配上问题截图,这样我们就能很清楚的知道客户的问题了。那么,我们就需要为我们的产品实现一个自定义截屏的功能,用户点完"截图"按钮后,框选任意区域,随后在框选的区域内进行圈选、画箭头、马赛克、直线、打字等操作,做完操作后用户可以选择保存框选区域的内容到本地或者直接发送给我们。聪明的开发者可能已经猜到了,这是QQ/微信的截图功能,我的开源项目正好做到了截图功能,在做之前我找了很多资料,没有发现web端有这种东西存在,于是我就决定参照QQ的截图自己实现一个并做成插件供大家使用。本文就跟大家分享下我在做这个"自定义截屏功能"时的实现思路以及过程,欢迎各位感兴趣的开发者阅读本文。写在前面本文插件的写法采用的是Vue3的compositionAPI,如果对其不了解的开发者请移步我的另一篇文章:使用Vue3的CompositionAPI来优化代码量实现思路我们先来看下QQ的截屏流程,进而分析它是怎么实现的。截屏流程分析我们先来分析下,截屏时的具体流程。点击截屏按钮后,我们会发现页面上所有动态效果都静止不动了,如下所示。随后,我们按住鼠标左键进行拖动,屏幕上会出现黑色蒙板,鼠标的拖动区域会出现镂空效果,如下所示(此处图片过大,无法展示请移步原文查看)完成拖拽后,框选区域的下方会出现工具栏,里面有框选、圈选、箭头、直线、画笔等工具,如下图所示。 image-20210201142541572点击工具栏中任意一个图标,会出现画笔选择区域,在这里可以选择画笔大小、颜色如下所示。随后,我们在框选的区域内进行拖拽就会绘制出对应的图形,如下所示。 image-20210201144004992 最后,点击截图工具栏的下载图标即可将图片保存至本地,或者点击对号图片会自动粘贴到聊天输入框,如下所示。截屏实现思路通过上述截屏流程,我们便得到了下述实现思路:获取当前可视区域的内容,将其存储起来为整个cnavas画布绘制蒙层在获取到的内容中进行拖拽,绘制镂空选区选择截图工具栏的工具,选择画笔大小等信息在选区内拖拽绘制对应的图形将选区内的内容转换为图片实现过程我们分析出了实现思路,接下来我们将上述思路逐一进行实现。获取当前可视区域内容当点击截图按钮后,我们需要获取整个可视区域的内容,后续所有的操作都是在获取的内容上进行的,在web端我们可以使用canvas来实现这些操作。那么,我们就需要先将body区域的内容转换为canvas,如果要从零开始实现这个转换,有点复杂而且工作量很大。还好在前端社区中有个开源库叫html2canvas可以实现将指定dom转换为canvas,我们就采用这个库来实现我们的转换。接下来,我们来看下具体实现过程:新建一个名为screen-short.vue的文件,用于承载我们的整个截图组件。首先我们需要一个canvas容器来显示转换后的可视区域内容<template> <teleport to="body"> <!--截图区域--> <canvas id="screenShotContainer" :width="screenShortWidth" :height="screenShortHeight" ref="screenShortController" ></canvas> </teleport> </template>此处只展示了部分代码,完整代码请移步:screen-short.vue在组件挂载时,调用html2canvas提供的方法,将body中的内容转换为canvas,存储起来。import html2canvas from "html2canvas"; import InitData from "@/module/main-entrance/InitData"; export default class EventMonitoring { // 当前实例的响应式data数据 private readonly data: InitData; // 截图区域canvas容器 private screenShortController: Ref<HTMLCanvasElement | null>; // 截图图片存放容器 private screenShortImageController: HTMLCanvasElement | undefined; constructor(props: Record<string, any>, context: SetupContext<any>) { // 实例化响应式data this.data = new InitData(); // 获取截图区域canvas容器 this.screenShortController = this.data.getScreenShortController(); onMounted(() => { // 设置截图区域canvas宽高 this.data.setScreenShortInfo(window.innerWidth, window.innerHeight); html2canvas(document.body, {}).then(canvas => { // 装载截图的dom为null则退出 if (this.screenShortController.value == null) return; // 存放html2canvas截取的内容 this.screenShortImageController = canvas; }此处只展示了部分代码,完整代码请移步:EventMonitoring.ts为canvas画布绘制蒙层我们拿到了转换后的dom后,我们就需要绘制一个透明度为0.6的黑色蒙层,告知用户你现在处于截屏区域选区状态。具体实现过程如下:创建DrawMasking.ts文件,蒙层的绘制逻辑在此文件中实现,代码如下。/** * 绘制蒙层 * @param context 需要进行绘制canvas export function drawMasking(context: CanvasRenderingContext2D) { // 清除画布 context.clearRect(0, 0, window.innerWidth, window.innerHeight); // 绘制蒙层 context.save(); context.fillStyle = "rgba(0, 0, 0, .6)"; context.fillRect(0, 0, window.innerWidth, window.innerHeight); // 绘制结束 context.restore(); }⚠️注释已经写的很详细了,对上述API不懂的开发者请移步:clearRect、save、fillStyle、fillRect、restore在html2canvas函数回调中调用绘制蒙层函数html2canvas(document.body, {}).then(canvas => { // 获取截图区域画canvas容器画布 const context = this.screenShortController.value?.getContext("2d"); if (context == null) return; // 绘制蒙层 drawMasking(context); })绘制镂空选区我们在黑色蒙层中拖拽时,需要获取鼠标按下时的起始点坐标以及鼠标移动时的坐标,根据起始点坐标和移动时的坐标,我们就可以得到一个区域,此时我们将这块区域的蒙层凿开,将获取到的canvas图片内容绘制到蒙层下方,这样我们就实现了镂空选区效果。整理下上述话语,思路如下:监听鼠标按下、移动、抬起事件获取鼠标按下、移动时的坐标根据获取到的坐标凿开蒙层将获取到的canvas图片内容绘制到蒙层下方实现镂空选区的拖拽与缩放实现的效果如下:具体代码如下:export default class EventMonitoring { // 当前实例的响应式data数据 private readonly data: InitData; // 截图区域canvas容器 private screenShortController: Ref<HTMLCanvasElement | null>; // 截图图片存放容器 private screenShortImageController: HTMLCanvasElement | undefined; // 截图区域画布 private screenShortCanvas: CanvasRenderingContext2D | undefined; // 图形位置参数 private drawGraphPosition: positionInfoType = { startX: 0, startY: 0, width: 0, height: 0 // 临时图形位置参数 private tempGraphPosition: positionInfoType = { startX: 0, startY: 0, width: 0, height: 0 // 裁剪框边框节点坐标事件 private cutOutBoxBorderArr: Array<cutOutBoxBorder> = []; // 裁剪框顶点边框直径大小 private borderSize = 10; // 当前操作的边框节点 private borderOption: number | null = null; // 点击裁剪框时的鼠标坐标 private movePosition: movePositionType = { moveStartX: 0, moveStartY: 0 // 裁剪框修剪状态 private draggingTrim = false; // 裁剪框拖拽状态 private dragging = false; // 鼠标点击状态 private clickFlag = false; constructor(props: Record<string, any>, context: SetupContext<any>) { // 实例化响应式data this.data = new InitData(); // 获取截图区域canvas容器 this.screenShortController = this.data.getScreenShortController(); onMounted(() => { // 设置截图区域canvas宽高 this.data.setScreenShortInfo(window.innerWidth, window.innerHeight); html2canvas(document.body, {}).then(canvas => { // 装载截图的dom为null则退出 if (this.screenShortController.value == null) return; // 存放html2canvas截取的内容 this.screenShortImageController = canvas; // 获取截图区域画canvas容器画布 const context = this.screenShortController.value?.getContext("2d"); if (context == null) return; // 赋值截图区域canvas画布 this.screenShortCanvas = context; // 绘制蒙层 drawMasking(context); // 添加监听 this.screenShortController.value?.addEventListener( "mousedown", this.mouseDownEvent this.screenShortController.value?.addEventListener( "mousemove", this.mouseMoveEvent this.screenShortController.value?.addEventListener( "mouseup", this.mouseUpEvent // 鼠标按下事件 private mouseDownEvent = (event: MouseEvent) => { this.dragging = true; this.clickFlag = true; const mouseX = nonNegativeData(event.offsetX); const mouseY = nonNegativeData(event.offsetY); // 如果操作的是裁剪框 if (this.borderOption) { // 设置为拖动状态 this.draggingTrim = true; // 记录移动时的起始点坐标 this.movePosition.moveStartX = mouseX; this.movePosition.moveStartY = mouseY; } else { // 绘制裁剪框,记录当前鼠标开始坐标 this.drawGraphPosition.startX = mouseX; this.drawGraphPosition.startY = mouseY; // 鼠标移动事件 private mouseMoveEvent = (event: MouseEvent) => { this.clickFlag = false; // 获取裁剪框位置信息 const { startX, startY, width, height } = this.drawGraphPosition; // 获取当前鼠标坐标 const currentX = nonNegativeData(event.offsetX); const currentY = nonNegativeData(event.offsetY); // 裁剪框临时宽高 const tempWidth = currentX - startX; const tempHeight = currentY - startY; // 执行裁剪框操作函数 this.operatingCutOutBox( currentX, currentY, startX, startY, width, height, this.screenShortCanvas // 如果鼠标未点击或者当前操作的是裁剪框都return if (!this.dragging || this.draggingTrim) return; // 绘制裁剪框 this.tempGraphPosition = drawCutOutBox( startX, startY, tempWidth, tempHeight, this.screenShortCanvas, this.borderSize, this.screenShortController.value as HTMLCanvasElement, this.screenShortImageController as HTMLCanvasElement ) as drawCutOutBoxReturnType; // 鼠标抬起事件 private mouseUpEvent = () => { // 绘制结束 this.dragging = false; this.draggingTrim = false; // 保存绘制后的图形位置信息 this.drawGraphPosition = this.tempGraphPosition; // 如果工具栏未点击则保存裁剪框位置 if (!this.data.getToolClickStatus().value) { const { startX, startY, width, height } = this.drawGraphPosition; this.data.setCutOutBoxPosition(startX, startY, width, height); // 保存边框节点信息 this.cutOutBoxBorderArr = saveBorderArrInfo( this.borderSize, this.drawGraphPosition }⚠️绘制镂空选区的代码较多,此处仅仅展示了鼠标的三个事件监听的相关代码,完整代码请移步:EventMonitoring.ts绘制裁剪框的代码如下/** * 绘制裁剪框 * @param mouseX 鼠标x轴坐标 * @param mouseY 鼠标y轴坐标 * @param width 裁剪框宽度 * @param height 裁剪框高度 * @param context 需要进行绘制的canvas画布 * @param borderSize 边框节点直径 * @param controller 需要进行操作的canvas容器 * @param imageController 图片canvas容器 * @private export function drawCutOutBox( mouseX: number, mouseY: number, width: number, height: number, context: CanvasRenderingContext2D, borderSize: number, controller: HTMLCanvasElement, imageController: HTMLCanvasElement // 获取画布宽高 const canvasWidth = controller?.width; const canvasHeight = controller?.height; // 画布、图片不存在则return if (!canvasWidth || !canvasHeight || !imageController || !controller) return; // 清除画布 context.clearRect(0, 0, canvasWidth, canvasHeight); // 绘制蒙层 context.save(); context.fillStyle = "rgba(0, 0, 0, .6)"; context.fillRect(0, 0, canvasWidth, canvasHeight); // 将蒙层凿开 context.globalCompositeOperation = "source-atop"; // 裁剪选择框 context.clearRect(mouseX, mouseY, width, height); // 绘制8个边框像素点并保存坐标信息以及事件参数 context.globalCompositeOperation = "source-over"; context.fillStyle = "#2CABFF"; // 像素点大小 const size = borderSize; // 绘制像素点 context.fillRect(mouseX - size / 2, mouseY - size / 2, size, size); context.fillRect( mouseX - size / 2 + width / 2, mouseY - size / 2, size, context.fillRect(mouseX - size / 2 + width, mouseY - size / 2, size, size); context.fillRect( mouseX - size / 2, mouseY - size / 2 + height / 2, size, context.fillRect( mouseX - size / 2 + width, mouseY - size / 2 + height / 2, size, context.fillRect(mouseX - size / 2, mouseY - size / 2 + height, size, size); context.fillRect( mouseX - size / 2 + width / 2, mouseY - size / 2 + height, size, context.fillRect( mouseX - size / 2 + width, mouseY - size / 2 + height, size, // 绘制结束 context.restore(); // 使用drawImage将图片绘制到蒙层下方 context.save(); context.globalCompositeOperation = "destination-over"; context.drawImage( imageController, controller?.width, controller?.height context.restore(); // 返回裁剪框临时位置信息 return { startX: mouseX, startY: mouseY, width: width, height: height
在组件中使用定义完相应死变量后,我们就可以在组件中导入使用了,部分代码如下所示,完整代码请移步:message-display.vueimport initData from "@/module/message-display/main-entrance/InitData"; export default defineComponent({ setup(props, context) { // 初始化组件需要的data数据 const { createDisSrc, resourceObj, messageContent, emoticonShowStatus, emojiList, toolbarList, senderMessageList, isBottomOut, audioCtx, arrFrequency, pageStart, pageEnd, pageNo, pageSize, sessionMessageData, msgListPanelHeight, isLoading, isLastPage, msgTotals, isFirstLoading, messagesContainer, msgInputContainer, selectImg } = initData(); // 返回组件需要用到的方法 return { createDisSrc, resourceObj, messageContent, emoticonShowStatus, emojiList, toolbarList, senderMessageList, isBottomOut, audioCtx, arrFrequency, pageStart, pageEnd, pageNo, pageSize, sessionMessageData, msgListPanelHeight, isLoading, isLastPage, msgTotals, isFirstLoading, messagesContainer, msgInputContainer, selectImg })我们定义后响应式变量后,就可以在拆分出来的文件中导入initData函数,访问里面存储的变量了。在文件中访问initData我将页面内所有的事件监听也拆分成了文件,放在了EventMonitoring.ts中,在事件监听的处理函数是需要访问initData里存储的变量的,接下来我们就来看下如何访问,部分代码如下所示,完整代码请移步EventMonitoring.ts)import { computed, ComputedRef, watch, getCurrentInstance, toRefs } from "vue"; import { useStore } from "vuex"; import initData from "@/module/message-display/main-entrance/InitData"; import { SetupContext } from "@vue/runtime-core"; import _ from "lodash"; export default function eventMonitoring( props: messageDisplayPropsType, context: SetupContext<any> userID: ComputedRef<string>; onlineUsers: ComputedRef<number>; } | void { const $store = useStore(); const currentInstance = getCurrentInstance(); // 获取传递的参数 const data = initData(); // 将props改为响应式 const prop = toRefs(props); // 获取data中的数据 const senderMessageList = data.senderMessageList; const sessionMessageData = data.sessionMessageData; const pageStart = data.pageStart; const pageEnd = data.pageEnd; const pageNo = data.pageNo; const isLastPage = data.isLastPage; const msgTotals = data.msgTotals; const msgListPanelHeight = data.msgListPanelHeight; const isLoading = data.isLoading; const isFirstLoading = data.isFirstLoading; const listId = data.listId; const messageStatus = data.messageStatus; const buddyId = data.buddyId; const buddyName = data.buddyName; const serverTime = data.serverTime; const messagesContainer = data.messagesContainer as Ref<HTMLDivElement>; // 监听listID改变 watch(prop.listId, (newMsgId: string) => { listId.value = newMsgId; messageStatus.value = prop.messageStatus.value; buddyId.value = prop.buddyId.value; buddyName.value = prop.buddyName.value; serverTime.value = prop.serverTime.value; // 消息id发生改变,清空消息列表数据 senderMessageList.length = 0; // 初始化分页数据 sessionMessageData.length = 0; pageStart.value = 0; pageEnd.value = 0; pageNo.value = 1; isLastPage.value = false; msgTotals.value = 0; msgListPanelHeight.value = 0; isLoading.value = false; isFirstLoading.value = true; }正如代码中那样,在文件中使用时,拿出initData中对应的变量,需要修改其值时,只需要修改他的value即可。至此,有关compositionAPI的基本使用就跟大家讲解完了,下面将跟大家分享下我在实现过程中所踩的坑,以及我的解决方案。踩坑分享今天是周四,我周一开始决定使用CompositionAPI来重构我这个组件的,一直搞到昨天晚上才重构完成,前前后后踩了很多坑,正所谓踩坑越多你越强,这句话还是很有道理的😎。接下来就跟大家分享下我踩到的一些坑以及我的解决方案。dom操作我的组件需要对dom进行操作,在optionsAPI中可以使用this.$refs.xxx来访问组件dom,在setup中是没有this的,翻了下官方文档后,发现需要通过ref来定义,如下所示:<template> <div ref="msgInputContainer"></div> <ul v-for="(item, i) in list" :ref="el => { ulContainer[i] = el }"></ul> </template> <script lang="ts"> import { ref, reactive, onBeforeUpdate } from "vue"; setup(){ export default defineComponent({ // DOM操作,必须return否则不会生效 // 获取单一dom const messagesContainer = ref<HTMLDivElement | null>(null); // 获取列表dom const ulContainer = ref<HTMLUListElement>([]); const list = reactive([1, 2, 3]); // 列表dom在组件更新前必须初始化 onBeforeUpdate(() => { ulContainer.value = []; return { messagesContainer, list, ulContainer </script>访问vuex在setup中访问vuex需要通过useStore()来访问,代码如下所示:import { useStore } from "vuex"; const $store = useStore(); console.log($store.state.token);访问当前实例在组件中需要访问挂载在globalProperties上的东西,在setup中就需要通过getCurrentInstance()来访问了,代码如下所示:import { getCurrentInstance } from "vue"; const currentInstance = getCurrentInstance(); currentInstance?.appContext.config.globalProperties.$socket.sendObj({ code: 200, token: $store.state.token, userID: $store.state.userID, msg: $store.state.userID + "上线" });无法访问$options我重构的websocket插件是将监听消息接收方法放在options上的,需要通过this.$options.xxx来访问,文档翻了一圈没找到有关在setup中使用的内容,那看来是不能访问了,那么我只能选择妥协,把插件挂载在options上的方法放到globalProperties上,这样问题就解决了。内置方法只能在setup中访问如上所述,我们使用到了getCurrentInstance和useStore,这两个内置方法还有initData中定义的那些响应式数据,只有在setup中使用时才能拿到数据,否则就是null。我的文件是拆分出去的,有些函数是运行在某个拆分出来的文件中的,不可能都在setup中执行一遍的,响应式变量也不可能全当作参数进行传递的,为了解决这个问题,我有试过使用provide注入然后通过inject访问,结果运行后发现不好使,控制台报黄色警告说provide和inject只能运行在setup中,我直接裂开,当时发了一条沸点求助了下,到了晚上也没得到解决方案😪。经过一番求助后,我的好友@前端印象给我提供了一个思路,成功的解决了这个问题,也就是我上面initData的做法,将响应式变量定义在导出函数的外面,这样我们在拆分出来的文件中导入initData方法时,里面的变量都是指向同一个地址,可以直接访问存储在里面的变量且不会将其进行初始化。至于getCurrentInstance和useStore访问出现null的情景,还有props、emit的使用问题,我们可以在initData的导出函数内部定义set方法,在setup里的方法中获取到实例后,通过set方法将其设置进我们定义的变量中。至此,问题就完美解决了,最后跟大家看下优化后的组件代码,393行😁 image-20210114201837539 项目地址项目地址:chat-system-github在线体验地址:chat-system写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言在我的开源项目中有一个组件是用来发送消息和展示消息的,这个组件的逻辑很复杂也是我整个项目的灵魂所在,单文件代码有1100多行。我每次用webstorm编辑这个文件时,电脑cpu温度都会飙升并伴随着卡顿。就在前几天我终于忍不住了,意识到了Vue2的optionsAPI的缺陷,决定用Vue3的CompositionAPI来解决这个问题,本文就跟大家分享下我在优化过程中踩到的坑以及我所采用的解决方案,欢迎各位感兴趣的开发者阅读本文。问题分析我们先来看看组件的整体代码结构,如下图所示: image-20210114095802363 template部分占用267行script部分占用889行style部分为外部引用占用1行罪魁祸首就是script部分,本文要优化的就是这一部分的代码,我们再来细看下script中的代码结构:props部分占用6行data部分占用52行created部分占用8行mounted部分占用98行methods部分占用672行emits部分占用6行computed部分占用8行watch部分占用26行现在罪魁祸首是methods部分,那么我们只需要把methods部分的代码拆分出去,单文件代码量就大大减少了。优化方案经过上述分析后,我们已经知道了问题所在,接下来就跟大家分享下我一开始想到的方案以及最终所采用的方案。直接拆分成文件一开始我觉得既然methods方法占用的行数太多,那么我在src下创建一个methods文件夹,把每个组件中的methods的方法按照组件名进行划分,创建对应的文件夹,在对应的组件文件夹内部,将methods中的方法拆分成独立的ts文件,最后创建index.ts文件,将其进行统一导出,在组件中使用时按需导入index.ts中暴露出来的模块,如下图所示: image-20210114103824562 创建methods文件夹把每个组件中的methods的方法按照组件名进行划分,创建对应的文件夹,即:message-display将methods中的方法拆分成独立的ts文件,即:message-display文件夹下的ts文件创建index.ts文件,即:methods下的index.ts文件index.ts代码如下所示,我们将拆分的模块方法进行导入,然后统一export出去import compressPic from "@/methods/message-display/CompressPic"; import pasteHandle from "@/methods/message-display/PasteHandle"; export { compressPic, pasteHandle };在组件中使用最后,我们在组件中按需导入即可,如下所示:import { compressPic, pasteHandle } from "@/methods/index"; export default defineComponent({ mounted() { compressPic(); pasteHandle(); })运行结果当我自信满满的开始跑项目时,发现浏览器的控制台报错了,提示我this未定义,突然间我意识到将代码拆分成文件后,this是指向那个文件的,并没有指向当前组件实例,当然可以将this作为参数传进去,但我觉得这样并不妥,用到一个方法就传一个this进去,会产生很多冗余代码,因此这个方案被我pass了。使用mixins前一个方案因为this的问题以失败告终,在Vue2.x的时候官方提供了mixins来解决this问题,我们使用mixin来定义我们的函数,最后使用mixins进行混入,这样就可以在任意地方使用了。由于mixins是全局混入的,一旦有重名的mixin原来的就会被覆盖,所以这个方案也不合适,pass。 image-20210114111746208使用CompositionAPI上述两个方案都不合适,那 么CompositionAPI就刚好弥补上述方案的短处,成功的实现了我们想要实现的需求。我们先来看看什么是CompositionAPI,正如文档所述,我们可以将原先optionsAPI中定义的函数以及这个函数需要用到的data变量,全部归类到一起,放到setup函数里,功能开发完成后,将组件需要的函数和data在setup进行return。setup函数在创建组件之前执行,因此它是没有this的,这个函数可以接收2个参数: props和context,他们的类型定义如下:interface Data { [key: string]: unknown interface SetupContext { attrs: Data slots: Slots emit: (event: string, ...args: unknown[]) => void function setup(props: Data, context: SetupContext): Data我的组件需要拿到父组件传过来的props中的值,需要通过emit来向父组件传递数据,props和context这两个参数正好解决了我这个问题。setup又是个函数,也就意味着我们可以将所有的函数拆分成独立的ts文件,然后在组件中导入,在setup中将其return给组件即可,这样就很完美的实现了一开始我们一开始所说的的拆分。实现思路接下来的内容会涉及到响应性API,如果对响应式API不了解的开发者请先移步官方文档。我们分析出方案后,接下来我们就来看看具体的实现路:在组件的导出对象中添加setup属性,传入props和context在src下创建module文件夹,将拆分出来的功能代码按组件进行划分将每一个组件中的函数进一步按功能进行细分,此处我分了四个文件夹出来common-methods 公共方法,存放不需要依赖组件实例的方法components-methods 组件方法,存放当前组件模版需要使用的方法main-entrance 主入口,存放setup中使用的函数split-method 拆分出来的方法,存放需要依赖组件实例的方法,setup中函数拆分出来的文件也放在此处在主入口文件夹中创建InitData.ts文件,该文件用于保存、共享当前组件需要用到的响应式data变量所有函数拆分完成后,我们在组件中将其导入,在setup中进行return即可实现过程接下来我们将上述思路进行实现。添加setup选项我们在vue组件的导出部分,在其对象内部添加setup选项,如下所示:<template> <!---其他内容省略--> </template> <script lang="ts"> export default defineComponent({ name: "message-display", props: { listId: String, // 消息id messageStatus: Number, // 消息类型 buddyId: String, // 好友id buddyName: String, // 好友昵称 serverTime: String // 服务器时间 setup(props, context) { // 在此处即可写响应性API提供的方法,注意⚠️此处不能用this </script>创建module模块我们在src下创建module文件夹,用于存放我们拆分出来的功能代码文件。如下所示,为我创建好的目录,我的划分依据是将相同类别的文件放到一起,每个文件夹的所代表的含义已在实现思路进行说明,此处不作过多解释。创建InitData.ts文件我们将组件中用到的响应式数据,统一在这里进行定义,然后在setup中进行return,该文件的部分代码定义如下,完整代码请移步:InitData.tsimport { reactive, getCurrentInstance, ComponentInternalInstance } from "vue"; import { emojiObj, messageDisplayDataType, msgListType, toolbarObj } from "@/type/ComponentDataType"; import { Store, useStore } from "vuex"; // DOM操作,必须return否则不会生效 const messagesContainer = ref<HTMLDivElement | null>(null); const msgInputContainer = ref<HTMLDivElement | null>(null); const selectImg = ref<HTMLImageElement | null>(null); // 响应式Data变量 const messageContent = ref<string>(""); const emoticonShowStatus = ref<string>("none"); const senderMessageList = reactive([]); const isBottomOut = ref<boolean>(true); let listId = ref<string>(""); let messageStatus = ref<number>(0); let buddyId = ref<string>(""); let buddyName = ref<string>(""); let serverTime = ref<string>(""); let emit: (event: string, ...args: any[]) => void = () => { return 0; // store与当前实例 let $store = useStore(); let currentInstance = getCurrentInstance(); export default function initData(): messageDisplayDataType { // 定义set方法,将props中的数据写入当前实例 const setData = ( listIdParam: Ref<string>, messageStatusParam: Ref<number>, buddyIdParam: Ref<string>, buddyNameParam: Ref<string>, serverTimeParam: Ref<string>, emitParam: (event: string, ...args: any[]) => void ) => { listId = listIdParam; messageStatus = messageStatusParam; buddyId = buddyIdParam; buddyName = buddyNameParam; serverTime = serverTimeParam; emit = emitParam; const setProperty = ( storeParam: Store<any>, instanceParam: ComponentInternalInstance | null ) => { $store = storeParam; currentInstance = instanceParam; // 返回组件需要的Data return { messagesContainer, msgInputContainer, selectImg, $store, emoticonShowStatus, currentInstance, // .... 其他部分省略.... }⚠️细心的开发者可能已经发现,我把响应式变量定义在导出的函数外面了,之所以这么做是因为setup的一些特殊原因,在下面的踩坑章节我将会详解我为什么要这样做。
前言上周跟大家分享了如何使用vue的自定义指令实现自定义浏览器右键菜单,大家都觉得挺有意思的,这次我把它做成了插件,上传到了npm仓库。在做这个插件的过程中,踩了蛮多坑,本文就跟大家分享下我的实现思路以及过程,欢迎各位感兴趣的开发者阅本文。环境搭建一开始我是直接用的typescript的tsc命令进行打包的,但是我的插件里用到了vue、scss,发现要把这些文件打包进去需要自己去配webpack。我记得好久之前,我用Vue CLI 2.x创建项目时,可以选择当前要创建的项目是插件还是web项目,现在用的是Vue ClI 4.x了,在创建项目时没看到有这个选项。于是,我带着侥幸的心理,去Vue CLI 官网找了一波,还真就被我找到了,它的build指令有个target选项,可以选择将其打包成一个插件,它的具体使用方法:vue-cli-service build。既然Vue CLI提供了现成的解决方案,那就用它提供的吧。创建项目在终端进入你的项目目录,使用create命令创建一个名为vue-right-click-menu-next的项目vue create vue-right-click-menu-next在接下来的步骤中,选择自定义配置,选vue3, node-sass, eslint+prettier, typescript这些选项配置依赖项项目创建好后,我们删掉CLI初始化时创建的东西,然后修改package.json中的内容。在package.json中,CLI默认是把vue和core-js放在dependencies下的,我们开发的插件是要给其他开发者引用的,如果我们打包的产物中包含Vue包的话可能会引发各种问题,比如用户可能会在引入我们的包之后会在runtime时创建两个不用的Vue实例,所以vue插件的package.json里一定不能将其放在dependencies中,而是要放在peerDependencies中,表明会从引用者的其他的包中引入相对应的包,而不会在这个包里直接引入。在package.json中添加下述代码,移除原来dependencies下的依赖。"peerDependencies": { "core-js": "^3.6.5", "vue": "^3.0.0" }在devDependencies中添加git提交规范相关依赖{ "@commitlint/cli": "^11.0.0", "@commitlint/config-angular": "^11.0.0", "commitizen": "^4.2.2", "cz-conventional-changelog": "^3.3.0", "husky": "^4.3.0", }添加config和husky配置changelog生成地址和强制编辑器提交代码走我们定义的规范{ "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" }最后,在script中添加提交命令与生成changelog的命令{ "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", "commit": "git-cz" }配置打包命令由文档可知,可以通过vue-cli-service build --target lib --name myLib [entry]命令,将一个单独的入口构建为一个库。那么,我们就可以在package.json的script标签里添加build命令用以执行插件的打包,代码如下。vueRightMenuPlugin 打包后的文件名src/main.ts 插件的入口文件{ "build": "vue-cli-service build --target lib --name vueRightMenuPlugin src/main.ts", }由于我们的插件启用了typescript,使用它的默认打包,不会帮我们生成ts声明文件,使用我们插件的开发者项目可能会启用typescript,在引用插件时就会报错声明文件不存在,因此我们需要额外做下述操作:在tsconfig.jsonz中添加下述代码,打包时在项目的指定位置自动生成配置文件。{ "declaration": true,// 是否生成声明文件 "declarationDir": "dist/lib",// 声明文件打包的位置 }创建vue.config.js文件,关闭并行打包的一些相关配置。module.exports = { chainWebpack: config => { if (process.env.NODE_ENV === "production") { config.module.rule("ts").uses.delete("cache-loader"); config.module .rule("ts") .use("ts-loader") .loader("ts-loader") .tap(opts => { opts.transpileOnly = false; opts.happyPackMode = false; return opts; parallel: false };做完上述操作后,我们运行打包命令时就能自动生成声明文件了。强制css内联当我把插件开发完,测试时发现我引用的组件样式丢了,找了好久问题,最后在CLI的文档中找到了问题所在,他有个css.extract属性,它使用来配置打包时是否将css样式提取到独立的文件中,Default: 生产环境下是 true,开发环境下是 false,我们打包时他默认是true,用户需要单独引入这个样式文件文件。我们可以通过手动将其设置为false,让其在打包时使用内联样式,这样就能解决样式失效的问题了,我们在vue.config.js中加入下述代码。module.exports = { // 强制css内联 css: { extract: false } }添加库描述做完上述操作,我们跟打包有关的相关的配置就弄好了,接下来我们在package.json中添加库的相关描述,让npm可以正确识别我们的插件。name 插件名称version 版本号description 插件简述private 是否私有main 库的入口文件位置(打包后的入口文件)types 库的声明文件位置publisher 库发布者repository 仓库信息keywords 关键词,在npm找包时所匹配的关键词author 库作者license 库遵守的开源协议bugs bug反馈地址homepage 库主页{ "name": "vue-right-click-menu-next", "version": "1.0.0", "description": "支持vue3的右键菜单插件", "private": false, "main": "dist/vueRightMenuPlugin.common.js", "types": "dist/lib/main.d.ts", "publisher": "magicalprogrammer@qq.com", "repository": { "type": "git", "url": "git+https://github.com/likaia/vue-right-click-menu-next.git" "keywords": [ "vuejs", "vue3", "vue", "rightMenu", "右键菜单", "vueRightMenu" "author": "likaia", "license": "MIT", "bugs": { "url": "https://github.com/likaia/vue-right-click-menu-next/issues" "homepage": "https://github.com/likaia/vue-right-click-menu-next#readme", }完整的配置文件请移步:package.json实现思路上篇文章我们的实现思路是需要vuex来做全局状态管理,控制右键菜单的显隐,这次我们要把它做成插件,再使用vuex的话,使用我们插件的人就需要必须引入vuex才行,那就有点不合适了。展示组件经过一番思考后,我有了下述思路:将右键菜单做成组件,通过props向组件传值。使用createApp来加载组件,向组件内部传值,创建一个组件容器创建一个div元素,将刚才的组件容器挂载到这个div元素上销毁组件完成上述操作后,我们就实现了让右键菜单显示到指定位置,但是要怎么隐藏它呢,经过一番思考后,我又想到了下述思路:将上述加载组件的实现封装成一个函数,将创建的div元素作为返回值。在插件全局声明一个变量menuVM,默认声明为null指令内部触发右键事件时,调用我们封装的函数,用menuVM去接收其返回值此时我们创建一个全局点击事件的监听,如果menuVM不为null,我们就把这个元素移除触发右键事件时,如果menuVM不为null,表示它上次点开的右键菜单没关,这样就会出问题,因此我们也需要将其从body中移除实现过程分析出实现思路后,接下来我们就着手将其实现吧。创建右键菜单组件在项目的src下创建components文件夹,在文件夹下创建right-menu.vue文件,样式和组件内容此处我们就不贴了,这里贴一下组件需要传的参数,完整代码请移步:right-menu.vue)<script lang="ts"> import { defineComponent } from "vue"; export default defineComponent({ name: "right-menu", props: { rightMenuStatus: String, rightMenuTop: String, rightMenuLeft: String, rightMenuList: Array </script>封装挂载组件函数我们可以通过vue3的createApp方法来加载一个组件,并给他传值,然后挂载到某个dom节点上,代码如下:/** * 将组件挂在到节点上 * @param comp 需要挂载的组件 * @param prop 向组件传的参数 const creatComp = function(comp: Component, prop: rightMenuAttribute) { // 创建组件 const app = createApp(comp, { ...prop // 创建一个div元素 const divEle = document.createElement("div"); // 将创建的div元素挂载追加至body里 document.body.appendChild(divEle); // 将组件挂载至刚才创建的div中 app.mount(divEle); // 返回挂载的元素,便于操作 return divEle; };在install中注册指令并显示菜单接下来,我们在插件的install方法中,注册一个vue指令rightClick,拦截它的右键事件,获取组件传过来来的参数,挂载组件,渲染右键菜单。代码如下:install(app: App): void { // 创建指令 app.directive("rightClick", (el, binding): boolean | void => { // 指令绑定元素元素是否存在判断 if (el == null) { throw "右键指令错误:元素未绑定"; el.oncontextmenu = function(e: MouseEvent) { if (menuVM != null) { // 销毁上次触发的右键菜单DOM document.body.removeChild(menuVM); menuVM = null; const textArray = binding.value.text; const handlerObj = binding.value.handler; // 菜单选项与事件处理函数是否存在 if (textArray == null || handlerObj == null) { throw "右键菜单内容与事件处理函数为必传项"; // 事件处理数组 const handlerArray = []; // 处理好的右键菜单 const menuList = []; // 将事件处理函数放入数组中 for (const key in handlerObj) { handlerArray.push(handlerObj[key]); if (textArray.length !== handlerArray.length) { // 文本数量与事件处理不对等 throw "右键菜单的每个选项,都必须有它的事件处理函数"; // 追加右键菜单数据 for (let i = 0; i < textArray.length; i++) { // 右键菜单对象, 添加名称 const menuObj: rightMenuObjType = { text: textArray[i], handler: handlerArray[i], id: i + 1 menuList.push(menuObj); // 鼠标点的坐标 const oX = e.clientX; const oY = e.clientY; // 动态挂载组件,显示右键菜单 menuVM = creatComp(rightMenu, { rightMenuStatus: "block", rightMenuTop: oY + "px", rightMenuLeft: oX + "px", rightMenuList: menuList return false; }创建监听销毁组件当用户点击完右键菜单后,我们需要对组件进行销毁,让其隐藏,因此我们在插件的install创建一个对body的点击监听,然后移除我们挂载的组件,代码如下:install(app: App): void { // 监听全局点击,销毁右键菜单dom document.body.addEventListener("click", () => { if (menuVM != null) { // 销毁右键菜单DOM document.body.removeChild(menuVM); menuVM = null; }完整代码请移步:main.ts)发布插件做完上述操作后,我们的插件就开发完成了,可以打包然后发布到npm仓库了。终端执行下述命令:npm publish --access public插件发布成功:vue-right-click-menu-next兼容Vue2.x插件是不兼容Vue2.x的,因为creatApp时Vue3新增的语法,一开始我本来想用Vue2.x的extend来实现组件挂载的,发现Vue3把这个语法舍弃了。这就造成了我需要写两套插件,维护两个插件。插件的逻辑层面没有啥区别,只有挂载组件写法的不同,Vue2.x中需要使用下述写法:/** * 将组件挂在到节点上 * @param comp 需要挂载的组件 * @param prop 向组件传的参数 const creatComp = function(comp, prop) { // 创建组件 const app = Vue.extend(comp); // 创建一个div元素 const divEle = document.createElement("div"); // 将创建的div元素挂载追加至body里 document.body.appendChild(divEle); // 将组件挂载至刚才创建的div中, 使用propsData进行传参 new app({ propsData: { ...prop }).$mount(divEle); // 返回挂载的元素,便于操作 return divEle; };插件地址:vue-right-click-menu项目地址本文中开发的插件代码地址:vue-right-click-menu | vue-right-click-menu-next在线体验地址:chat-system写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
进行单元测试做完上述操作后,最难弄的一关我们就已经搞定了,接下来我们来对一会需要使用的方法进行单元测试,确保其能够正常运行。创建一个名为RedisTest的Java文件,注入需要用到的相关类。redisOperatingUtil为我们的redis工具类subMessageMapper为聊天记录表的dao层@RunWith(SpringRunner.class) @SpringBootTest @Slf4j public class RedisTest { @Resource private RedisOperatingUtil redisOperatingUtil; @Resource private SubMessageMapper subMessageMapper; }接下来,我们看下SubMessage实体类的代码。package com.lk.entity; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @AllArgsConstructor // 聊天记录-消息内容 public class SubMessage { private Integer id; private String msgText; // 消息内容 private String createTime; // 创建时间 private String userName; // 用户名 private String userId; // 推送方用户id private String avatarSrc; // 推送方头像 private String msgId; // 接收方用户id private Boolean status; // 消息状态 }测试list数据的写入与获取在单元测试类内部加入下述代码:@Test public void testSerializableListRedisTemplate() { // 构造聊天记录实体类数据 SubMessage subMessage = new SubMessage(); subMessage.setAvatarSrc("https://www.kaisir.cn/uploads/1ece3749801d4d45933ba8b31403c685touxiang.jpeg"); subMessage.setUserId("1090192"); subMessage.setUserName("神奇的程序员"); subMessage.setMsgText("你好"); subMessage.setMsgId("2901872"); subMessage.setCreateTime("2020-12-12 18:54:06"); subMessage.setStatus(false); // 将聊天记录对象保存到redis中 redisOperatingUtil.listRightPush("subMessage", subMessage); // 获取list中的数据 Object resultObj = redisOperatingUtil.listRange("subMessage", 0, redisOperatingUtil.listLen("subMessage")); // 将Object安全的转为List List<SubMessage> resultList = ObjectToOtherUtil.castList(resultObj, SubMessage.class); // 遍历获取到的结果 if (resultList != null) { for (SubMessage message : resultList) { System.out.println(message.getUserName()); }在上述代码中,我们从redis中取出的数据是Object类型的,我们要将它转换为与之对应的实体类,一开始我是用的类型强转,但是idea会报黄色警告,于是就写了一个工具类用于将Object对象安全的转换为与之对应的类型,代码如下:package com.lk.utils; import java.util.ArrayList; import java.util.List; public class ObjectToOtherUtil { public static <T> List<T> castList(Object obj, Class<T> clazz) { List<T> result = new ArrayList<>(); if (obj instanceof List<?>) { for (Object o : (List<?>) obj) { result.add(clazz.cast(o)); return result; return null; }执行后,我们看看redis是否有保存到我们写入的数据,如下所示,已经成功保存。 image-20201213163924700 我们再来看看,代码的执行结果,看看有没有成功获取到数据,如下图所示,也成功取到了。 image-20201213164038308 注意:如果你的项目对websocket进行了启动配置,可能会导致单元测试失败,报错java.lang.IllegalStateException: Failed to load ApplicationContext,解决方案就是注释掉websocket配置文件中的@Configuration即可。测试list数据的取出当我们把redis中存储的数据迁移到mysql后,需要删除redis中的数据,一开始我用的是它的delete方法,但是他的delete方法只能删除与之匹配的值,不能选择一个区间进行删除,于是就决定用它的pop方法进行出栈操作。我们来测试下工具类中的listPopLeftKey方法。@Test public void testListPop() { long item = 0; // 获取存储在redis中聊天记录的条数 long messageListSize = redisOperatingUtil.listLen("subMessage"); for (int i = 0; i < messageListSize; i++) { // 从头向尾取出链表中的元素 SubMessage messageResult = (SubMessage) redisOperatingUtil.listPopLeftKey("subMessage"); log.info(messageResult.getMsgText()); item++; log.info(item+"条数据已成功取出"); }执行结果如下所示,成功取出了redis中存储的两条数据。 image-20201213170726492测试聊天记录转移至数据库接下来我们在redis中放入三条数据用于测试 image-20201213171623890 我们测试下将redis中的数据取出,然后写入数据库,代码如下:// 测试聊天记录转移数据库 @Test public void testRedisToMysqlTask() { // 获取存储在redis中聊天记录的条数 long messageListSize = redisOperatingUtil.listLen("subMessage"); // 写入数据库的数据总条数 long resultCount = 0; for (int i = 0; i < messageListSize; i++) { // 从头到尾取出链表中的元素 SubMessage subMessage= (SubMessage) redisOperatingUtil.listPopLeftKey("subMessage"); // 向数据库写入数据 int result = subMessageMapper.addMessageTextInfo(subMessage); if (result > 0) { // 写入成功 resultCount++; log.info(resultCount+ "条聊天记录,已写入数据库"); }执行结果如下,数据已成功写入数据库且redis中的数据也被删除。 image-20201213171834299 image-20201213171956311 image-20201213172031222 解析客户端数据保存至redis完成上述操作后,我们redis那一块的东西就搞定了,接下来就可以实现将客户端的数据存到redis里了。这里有个坑,因为websocket服务类中用到了@Component,会导致redis的工具类注入失败,出现null的情况,解决这个问题需要将当前类名声明为静态变量,然后在init中获取赋值redis工具类,代码如下:// 解决redis操作工具类注入为null的问题 public static WebSocketServer webSocketServer; @PostConstruct public void init() { webSocketServer = this; webSocketServer.redisOperatingUtil = this.redisOperatingUtil; }在websocket服务的@OnMessage注解中,收到客户端发送的消息,我们将其保存到redis中,代码如下:/** * 收到客户端消息后调用的方法 * @param message 客户端发送过来的消息 * // @param session 客户端会话 @OnMessage public void onMessage(String message) { // 客户端发送的消息 JSONObject jsReply = new JSONObject(message); // 添加在线人数 jsReply.put("onlineUsers", getOnlineCount()); if (jsReply.has("buddyId")) { // 获取推送方id String userId = jsReply.getString("userID"); // 获取被推送方id String buddyId = jsReply.getString("buddyId"); // 非测试数据则推送消息 if (!buddyId.equals("121710f399b84322bdecc238199d6888")) { // 发送消息至推送方 this.sendInfo(jsReply.toString(), userId); // 构造聊天记录实体类数据 SubMessage subMessage = new SubMessage(); subMessage.setAvatarSrc(jsReply.getString("avatarSrc")); subMessage.setUserId(jsReply.getString("userID")); subMessage.setUserName(jsReply.getString("username")); subMessage.setMsgText(jsReply.getString("msg")); subMessage.setMsgId(jsReply.getString("msgId")); subMessage.setCreateTime(DateUtil.getThisTime()); subMessage.setStatus(false); // 将聊天记录对象保存到redis中 webSocketServer.redisOperatingUtil.listRightPush("subMessage", subMessage); // 发送消息至被推送方 this.sendInfo(jsReply.toString(), buddyId); }做完上述操作后,收到客户端发送的消息就会自动写入redis。定时将redis的数据写入mysql接下来,我们使用quartz定时向mysql中写入数据,他执行定时任务的步骤分为2步:创建任务类编写任务内容在QuartzConfig文件中设置定时,执行第一步创建的任务。首先,创建quartzServer包,在其下创建RedisToMysqlTask.java文件,在此文件内实现redis写入mysql的代码package com.lk.quartzServer; import com.lk.dao.SubMessageMapper; import com.lk.entity.SubMessage; import com.lk.utils.RedisOperatingUtil; import lombok.extern.slf4j.Slf4j; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import org.springframework.scheduling.quartz.QuartzJobBean; import javax.annotation.Resource; // 将redis数据放进mysql中 @Slf4j public class RedisToMysqlTask extends QuartzJobBean { @Resource private RedisOperatingUtil redisOperatingUtil; @Resource private SubMessageMapper subMessageMapper; @Override protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException { // 获取存储在redis中聊天记录的条数 long messageListSize = redisOperatingUtil.listLen("subMessage"); // 写入数据库的数据总条数 long resultCount = 0; for (int i = 0; i < messageListSize; i++) { // 从头到尾取出链表中的元素 SubMessage subMessage= (SubMessage) redisOperatingUtil.listPopLeftKey("subMessage"); // 向数据库写入数据 int result = subMessageMapper.addMessageTextInfo(subMessage); if (result > 0) { // 写入成功 resultCount++; log.info(resultCount+ "条聊天记录,已写入数据库"); }在config包下创建QuartzConfig.java文件,创建定时任务package com.lk.config; import com.lk.quartzServer.RedisToMysqlTask; import org.quartz.*; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; * Quartz定时任务配置 @Configuration public class QuartzConfig { @Bean public JobDetail RedisToMysqlQuartz() { // 执行定时任务 return JobBuilder.newJob(RedisToMysqlTask.class).withIdentity("CallPayQuartzTask").storeDurably().build(); @Bean public Trigger CallPayQuartzTaskTrigger() { //cron方式,从每月1号开始,每隔三天就执行一次 return TriggerBuilder.newTrigger().forJob(RedisToMysqlQuartz()) .withIdentity("CallPayQuartzTask") .withSchedule(CronScheduleBuilder.cronSchedule("* * 4 1/3 * ?")) .build(); }这里我设置的定时任务是从每月1号开始,每隔三天就执行一次,Quartz定时任务采用的是cron表达式,自己算这个比较麻烦,这里推荐一个在线网站,可以很容易的生成表达式:Cron表达式生成器实现效果最后,配合Vue实现的浏览器端,跟大家展示下实现效果:效果视频:使用Vue实现单聊项目浏览器端代码地址:github/chat-system项目在线体验地址:chat-system写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言这几天在实现我开源项目的单聊功能,在实现过程中遇到了需要将聊天记录保存至数据库的问题,在收到消息时肯定不能直接存数据库,因为这样在高并发的场景下,数据库就炸了。于是,我就想到了redis这个东西,第一次听说它是在2年前,但是一直没时间玩他,现在终于遇到了需要使用它的场景,在用的时候学它,本文就跟大家分享下我的实现思路以及过程,欢迎各位感兴趣的开发者阅读本文。环境搭建我的项目是基于SpringBoot2.x搭建的,电脑已经安装了redis,用的maven作为jar包管理工具,所以只需要在maven中添加需要的依赖包即可,如果你用的是其他管理工具,请自行查阅如何添加依赖。本文需要用到依赖:Redis 、quartz,在pom.xml文件的dependencies标签下添加下述代码。<!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 定时任务调度 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> <version>2.3.7.RELEASE</version> </dependency>在application.yml文件中配置相关参数。spring: # redis配置 redis: host: 127.0.0.1 # redis地址 port: 6379 # 端口号 password: # 密码 timeout: 3000 # 连接超时时间,单位毫秒实现思路在websocket的服务中,收到客户端推送的消息后,我们对数据进行解析,构造聊天记录实体类,将其保存至redis中,最后我们使用quartz设置定时任务将redis的数据定时写入mysql中。我们将上述思路进行下整理:解析客户端数据,构造实体类将数据保存至redis使用quartz将redis中的数据定时写入mysql实现过程实现思路很简单,难在如何将实体类数据保存至redis,我们需要把redis这一块配置好后,才能继续实现我们的业务需求。redis支持的数据结构类型有:set 集合,string类型的无序集合,元素不允许重复hash 哈希表,键值对的集合,用于存储对象list 列表,链表结构zset有序集合string 字符串,最基本的数据类型,可以包含任何数据,比如一个序列化的对象,它的字符串大小上限是512MBredis的客户端分为jedis 和 lettuce,在SpringBoot2.x中默认客户端是使用lettuce实现的,因此我们不用做过多配置,在使用的时候通过RedisTemplate.xxx来对redis进行操作即可。自定义RedisTemplate在RedisTemplate中,默认是使用Java字符串序列化,将字符串存入redis后可读性很差,因此,我们需要对他进行自定义,使用Jackson 序列化,以 JSON 方式进行存储。我们在项目的config包下,创建一个名为LettuceRedisConfig的Java文件,我们再此文件中配置其默认序列化规则,它的代码如下:package com.lk.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; // 自定义RedisTemplate设置序列化器, 方便转换redis中的数据与实体类互转 @Configuration public class LettuceRedisConfig { * Redis 序列化配置 @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(connectionFactory); // 使用GenericJackson2JsonRedisSerializer替换默认序列化 GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer(); // 设置 Key 和 Value 的序列化规则 redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); // 初始化 RedisTemplate 序列化完成 redisTemplate.afterPropertiesSet(); return redisTemplate; }封装redis工具类做完上述操作后,通过RedisTemplate存储到redis中的数据就是json形式的了,接下来我们对其常用的操作封装成工具类,方便我们在项目中使用。在Utils包中创建一个名为RedisOperatingUtil,其代码如下:package com.lk.utils; import org.springframework.data.redis.connection.DataType; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @Component // Redis操作工具类 public class RedisOperatingUtil { @Resource private RedisTemplate<Object, Object> redisTemplate; * 指定 key 的过期时间 * @param key 键 * @param time 时间(秒) public void setKeyTime(String key, long time) { redisTemplate.expire(key, time, TimeUnit.SECONDS); * 根据 key 获取过期时间(-1 即为永不过期) * @param key 键 * @return 过期时间 public Long getKeyTime(String key) { return redisTemplate.getExpire(key, TimeUnit.SECONDS); * 判断 key 是否存在 * @param key 键 * @return 如果存在 key 则返回 true,否则返回 false public Boolean hasKey(String key) { return redisTemplate.hasKey(key); * 删除 key * @param key 键 public Long delKey(String... key) { if (key == null || key.length < 1) { return 0L; return redisTemplate.delete(Arrays.asList(key)); * 获取 Key 的类型 * @param key 键 public String keyType(String key) { DataType dataType = redisTemplate.type(key); assert dataType != null; return dataType.code(); * 批量设置值 * @param map 要插入的 key value 集合 public void barchSet(Map<String, Object> map) { redisTemplate.opsForValue().multiSet(map); * 批量获取值 * @param list 查询的 Key 列表 * @return value 列表 public List<Object> batchGet(List<String> list) { return redisTemplate.opsForValue().multiGet(Collections.singleton(list)); * 获取指定对象类型key的值 * @param key 键 * @return 值 public Object objectGetKey(String key) { return redisTemplate.opsForValue().get(key); * 设置对象类型的数据 * @param key 键 * @param value 值 public void objectSetValue(String key, Object value) { redisTemplate.opsForValue().set(key, value); * 向list的头部插入一条数据 * @param key 键 * @param value 值 public Long listLeftPush(String key, Object value) { return redisTemplate.opsForList().leftPush(key, value); * 向list的末尾插入一条数据 * @param key 键 * @param value 值 public Long listRightPush(String key, Object value) { return redisTemplate.opsForList().rightPush(key, value); * 向list头部添加list数据 * @param key 键 * @param value 值 public Long listLeftPushAll(String key, List<Object> value) { return redisTemplate.opsForList().leftPushAll(key, value); * 向list末尾添加list数据 * @param key 键 * @param value 值 public Long listRightPushAll(String key, List<Object> value) { return redisTemplate.opsForList().rightPushAll(key, value); * 通过索引设置list元素的值 * @param key 键 * @param index 索引 * @param value 值 public void listIndexSet(String key, long index, Object value) { redisTemplate.opsForList().set(key, index, value); * 获取列表指定范围内的list元素,正数则表示正向查找,负数则倒叙查找 * @param key 键 * @param start 开始 * @param end 结束 * @return boolean public Object listRange(String key, long start, long end) { return redisTemplate.opsForList().range(key, start, end); * 从列表前端开始取出数据 * @param key 键 * @return 结果数组对象 public Object listPopLeftKey(String key) { return redisTemplate.opsForList().leftPop(key); * 从列表末尾开始遍历取出数据 * @param key 键 * @return 结果数组 public Object listPopRightKey(String key) { return redisTemplate.opsForList().rightPop(key); * 获取list长度 * @param key 键 * @return 列表长度 public Long listLen(String key) { return redisTemplate.opsForList().size(key); * 通过索引获取list中的元素 * @param key 键 * @param index 索引(index>=0时,0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推) * @return 列表中的元素 public Object listIndex(String key, long index) { return redisTemplate.opsForList().index(key, index); * 移除list元素 * @param key 键 * @param count 移除数量("负数"则从列表倒叙查找删除 count 个对应的值; "整数"则从列表正序查找删除 count 个对应的值;) * @param value 值 * @return 成功移除的个数 public Long listRem(String key, long count, Object value) { return redisTemplate.opsForList().remove(key, count, value); * 截取指定范围内的数据, 移除不是范围内的数据 * @param key 操作的key * @param start 截取开始位置 * @param end 截取激素位置 public void listTrim(String key, long start, long end) { redisTemplate.opsForList().trim(key, start, end);
前言今天在设计开源项目的反馈信息表时遇到了emoji表情插入失败的问题,网上找了很多解决方案,答案五花八门,没找到好使的。经过一番折腾后,终于成功插入了emoji表情,本文就跟大家分享下我的实现过程,欢迎各位感兴趣的开发者阅读本文。写在前面我的服务器是Mac系统,mysql使用brew安装的,windows/linux它的配置文件位置可能有些不一样,具体根据真实情况而定。先跟大家看下它的报错信息:chat_system> UPDATE chat_system.feedback t SET t.comments = '反馈信息测试😂' WHERE t.id = 1 [2020-12-01 21:36:08] [HY000][1366] Incorrect string value: '\xF0\x9F\x98\x82' for column 'comments' at row 1 [2020-12-01 21:36:08] [HY000][1366] Incorrect string value: '\xF0\x9F\x98\x82' for column 'comments' at row 1实现思路因为数据库默认是UTF-8编码格式,普通的字符串占位3个字节而表情占位4字节,此时UTF-8就不够用了,需要采用utf8mb4字符集就能解决这个问题了。注意:utf8mb4字符集要求数据库版本高于5.5.3。那么,我们要做的事情如下所示:修改mysql配置文件,设置其编码格式修改数据库字符集编码修改数据库表字符集编码实现过程mysql默认读取配置的顺序为:/etc/my.cnf、/etc/mysql/my.cnf、/usr/local/etc/my.cnf、~/.my.cnf,如果对应目录下不存在这些配置文件,则需要自己新建一个。修改数据库配置文件my.cnf,我的文件位置在: /usr/local/etc下,添加下述代码[mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci init_connect = 'SET NAMES utf8mb4' character-set-client-handshake = false [client] default-character-set=utf8mb4 [mysql] default-character-set=utf8mb4修改数据库字符集编码,登录mysql后执行下述sql语句。# 设置数据库字符集编码,chat_system为数据库名称,根据自己的实际情况而来 ALTER DATABASE chat_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;修改数据库表的字符集编码,登录mysql后执行下述sql语句。# 设置数据库表字符集编码,chat_system.feedback_comment_reply为我的数据库下对应的表名称,根据自己的实际情况而来 ALTER TABLE chat_system.feedback_comment_reply CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;完成上述操作后,我们来看看是否修改成功,登录mysql后执行下述sql语句。SHOW VARIABLES WHERE Variable_name LIKE 'character%' OR Variable_name LIKE 'collation%';显示下属信息,则表示我们已经修改完成了。 image-20201201214142509测试用例我们来往插入一个emoji表情来测试下:UPDATE chat_system.feedback t SET t.comments = '反馈信息测试😂' WHERE t.id = 1;如下所示,没有报错,插入成功。 我们用dataGrap查看下数据库表中的数据,如下所示,它显示了一个?,应该是软件无法识别。 image-20201201214741613 讲道理,应该是插入成功了,我们用postman请求接口试下,成功显示出来了😄。 image-20201201215255287
前言昨天,我的开源项目成员给我提交了代码,我在用webstorm看他的代码时发现了一堆跟代码格式相关的黄色报错,我就问他这一堆黄色报错你的编辑器就没给你提示吗?他说他用的vscode没有提示这些报错。于是,我就亲自下载了vscode搞了下发现真没提示,在百度和掘金搜了下vscode配置eslint+prettier的文章没有一个好使的,终于在踩了很多坑后,配置成功了。本文就跟大家分享下如何在vscode上配置Eslint+Prettier,欢迎各位感兴趣的开发者阅读本文。写在前面本文中所使用的项目在package.json中已经装了相关依赖包,在项目根目录也有其对应的配置文件。webstorm是可以正确识别我的配置文件在保存时进行格式化的,vscode就不行了,本文的目的就是解决这个问题。本文中使用的项目地址:chat-system插件安装我们先需要为vscode安装相关插件。安装eslint、prettier插件插件使用这里你可以选择直接修改vscode的setting.json文件,这样的修改是本地的,无法做到同步,如果有其他人也是用的vscode,那么你要告诉他改什么改什么,他在去改,甚是麻烦。我这里选择在项目的根目录创建.vscode文件夹, 然后再在其下面创建setting.json文件,将这个文件夹同步到git,这样做vscode就会优先读取项目根目录下的配置文件了,完美的解决了刚才那个痛点。创建好文件后,添加下述配置:{ "[vue]": { "editor.defaultFormatter": "esbenp.prettier-vscode" "eslint.alwaysShowStatus": true, "eslint.format.enable": true, "eslint.packageManager": "yarn", "eslint.run": "onSave", "prettier.packageManager": "yarn", "eslint.validate": [ "vue", "javascript", "javascriptreact" "editor.codeActionsOnSave": { "source.fixAll.eslint": true "vetur.validation.template": false, "editor.formatOnPaste": true, "editor.formatOnType": true, "editor.formatOnSave": true, "files.eol": "\n" }注意:如果你启用了prettier,但是没有相关配置文件,editor.formatOnSave选项就要设置为false。不然会与vscode自身的保存起冲突接下来,我们来配置prettier,同样的在项目根目录创建.prettierrc.json文件,添加下述配置:{ "tabWidth": 2, "useTabs": false, "endOfLine": "auto", "singleQuote": false, "semi": true, "trailingComma": "none", "bracketSpacing": true }上述配置是我项目的相关规范,你可以按照你的实际需求去弄,对此不了解的可以去查阅官方文档。做完上述配置后,vscode就已经可以按照我们的规范来进行相应的提示了,按ctrl+s保存代码时其也会按照我们自定义的的规范进行格式化。写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言使用Git来管理项目时,项目负责人在搭建项目时会定义好代码的提交规范,如果没有按照规范是无法提交代码的,但是每次提交都手动写那些格式甚是麻烦,于是乎就有了commitizen这个工具。本文就跟大家讲解下如何使用commitizen这个工具来快速按照团队规范来提交代码,欢迎各位感兴趣的开发者阅读本文。插件安装全局安装commitizen插件yarn global add commitizen插件使用执行git cz命令,选择对应的提交信息# 命令解析 ## 本次提交你修改的类型是什么?使用方向键进行选择 Select the type of change that you're committing: (Use arrow keys) ❯ feat: A new feature # 实现新功能 fix: A bug fix # bug修复 docs: Documentation only changes # md文件修改 style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) # 样式修改 refactor: A code change that neither fixes a bug nor adds a feature # 功能重构 perf: A code change that improves performance # 性能提升 test: Adding missing tests or correcting existing tests # 与测试有关的更改 build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) # 影响到项目构建的相关修改 ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) # 对CI配置文件和脚本的修改 chore: Other changes that don't modify src or test files # 不会修改src或测试文件的更改 revert: Reverts a previous commit # 恢复上一次提交 ## 本次提交更改的范围 What is the scope of this change (e.g. component or file name): (press enter to skip) # 输入更改的内容后按回车,此处我的内容是:设计图 ## 本次提交修改内容的简短概括,最多89个字 Write a short, imperative tense description of the change (max 89 chars): (0) # 输入本次更改的内容后按回车,此处我的内容是:设计模块添加设计图 ## 本次提交修改内容的详细描述,用1,2,3..数字来描述,每一点之间用空格隔开 Provide a longer description of the change: (press enter to skip) # 此处我的内容是:1.添加反馈设计图 2.添加文件列表设计图 3.添加账户信息设计图 ## 是否有重大变化 Are there any breaking changes? (y/N) # 此处我输入的是N ## 此更改是否会影响到未解决的问题 Does this change affect any open issues? (y/N) # 此处我输入的是N最后,使用vscode的push即可去github看下效果,成功按照预先规定好的格式提交了写在最后我一直使用的webstorm,它有个插件叫git-commit-template可以界面化来引导你按规范提交代码。但是我的组员有人使用的开发工具是VSCode,我在它的插件市场找了一圈没有发现类似的工具,就只能用commitizen这个工具在命令行进行提交了。
前言20多天前,遇到一个日程表的业务需求,可以动态增加列、对单元格进行合并,结合公司的jsp项目的已有功能完成单元格的增、删、改操作。进行需求分析整理后,经过了一番查找,发现React版本的antd的表格组件功能很强大,可定制程度很高,可以助我完成这个业务需求的开发。由于要和jsp进行交互,所以在实现过程中,遇到了一些难题踩了挺多坑,本文就跟大家分享下我从0到1实现这个需求的过程与思路,欢迎各位感兴趣的开发者阅读本文。环境搭建因为公司的项目是基于jsp的,antd本想用Vue版本的,无奈它与jsp的一些语法冲突了跑不起来,于是就尝试了react版本的antd,它跑起来了没有发现任何兼容性问题,一切正常。给React点个赞👍。由于要与项目中已有的功能进行交互,没法用脚手架,我只能以cdn的方式引入react,如下所示,按顺序引入react、axios、lodah以及antd所需要的文件。<script crossOrigin type="text/javascript" src="lib/react.production.min.js"></script> <script crossOrigin type="text/javascript" src="lib/react-dom.production.min.js"></script> <script src="lib/babel.min.js"></script> <script type="text/javascript" src="lib/moment.min.js"></script> <script src="lib/lodash.min.js"></script> <script type="text/javascript" src="lib/antd.min.js"></script> <script type="text/javascript" src="lib/axios.min.js"></script> <link rel="stylesheet" href="lib/antd.min.css">上述用到的资源文件地址: react-antd-schedule/lib我们需要把react相关代码写在text/babel标签中,如下所示,我们打印antd和react看看是否有值。<script type="text/babel"> console.log("react"); console.log(React); console.log("antd") console.log(antd); </script>打开浏览器控制台,出现下述信息,代表我们的环境已经搭建成功。 image-20201119155715157接下来,我们写个HelloWord来测试下效果。<div id="root" style="width: 94%;overflow: hidden"></div> <script type="text/babel"> // 自定义hook const App = () => { const onChange = (date, dateString) => { console.log(date, dateString); return ( <div> React+antd引入成功 <br /> <antd.DatePicker onChange={onChange} /> </div> ReactDOM.render(<App />, document.getElementById("root")); </script>执行上述代码,打开浏览器如果看到下述效果,就证明我们的环境已经搭好了。 image-20201119161505912需要注意的是,CDN引入React和antd,他们是在全局暴露了一个对象,在使用它内部的方法时就需要React.xx、antd.xx来访问了。需求分析当我收到需求简述后,我对其进行了整理:表格列要展示的内容:日期、日程内容(接口动态返回),日程内容列用户可以自己手动增加。表格行展示的内容为每一天的数据,每一天的数据分为:上午、下午、晚上三个时间段。日程内容分为天日程和某个时间段的日程两种状态,如果为天日程则需要进行单元格合并。日程内容列的每个单元格有5种状态,需要通过某种方式来区分,让用户一眼就能看出当前日程处于什么状态。日程内容单元格的内容如果为空时,需要将单元格进行合并,显示一个增加图标,点击增加图标后,打开系统的弹窗进行增加操作,操作完成后,渲染内容至刚才点击的单元格。如果内容单元格有内容时,根据不同的状态,打开不同的弹窗进行改、删操作,操作完后,更新结果至对应的单元格。需求确定后,老板给我分了一个后端,跟后端沟通后开发周期估了1周,我页面估了2天的时间,剩下的3天与后端进行数据对接。2天后,我把页面弄完了,表格需要的数据格式也定义好了,把数据格式发给后端后,他说好,没问题。因为没有UI给设计图,所以第一版,我就凭着自己的直觉来弄了,搞出来的东西蛮丑的,下图就是我根据需求实现的页面。 image-20201119172808318然而,事情没有预想中那么顺利,我页面做好后,到开发周期的最后一天下午,后端把接口给我了,但返回的数据不是我预想的格式,我又进行了二次处理,页面渲染出来后,快到下班时间了,到了预估的开发时间没有完成需求,倒也能理解,毕竟后端那边要处理的数据比较复杂。本来预估了一周的开发时间,后面需求的不断增加、变更、UI设计效果图,我的页面代码也从一开始的100多行累加到现在的1000多行,这一套折腾下来,直到需求开发完成交给测试,花了20多天的时间。需求实现接下来,就跟大家分享下在实现这个需求时,遇到的难点、踩到的一些坑以及我的解决方案。最后实现的效果如下所示,实现代码请移步:react-antd-schedule/index.html image-20201119175256753动态增加列这个日程表用户可以通过点增加图标来增加一列日程,此时我们就需要往表格头部增加一列数据,一开始我觉得只要往antd的columns和dataSource中添加一条数据就行了,如下所示:const App = () => { const [columns, setColumns] = React.useState([]); const [optRecords, setOptRecords] = React.useState([]); //增加按钮函数 const btnClick = (e) => { index++; let columnsObj = { dataIndex: 'rcnr' + (index), title: '日程内容' + index, align: 'center', onCell: tdSet, render: rctd_render, // 表格列新增一列 columns.push(columnsObj) setColumns(columns); // 处理表格数据 for (let i = 0; i < optRecords.length; i++) { let key = "rcnr"+index; // 表格数据新增一条 optRecords[i][key] = {text:"", code:"0"} setOptRecords(optRecords); }当我在浏览器执行看效果时,发现没有生效,于是我下意识的打开了浏览器控制台看看是不是报错了,啪的一下,很快啊~新增加的那一列被渲染上去了,我大E了啊,antd不讲武德啊。于是,我多试了几次,发现还是不渲染,打开控制台后就奇迹般的渲染上去了,有点摸不着头脑,就求助了下网友,我才恍然大悟,原来是antd没有监听到引用地址的改变,得到了下述解决方案,用一个函数去处理它,让antd监听到引用地址改变,它才会将数据进行渲染。const App = () => { const [optRecords, setOptRecords] = React.useState([]); const [columns, setColumns] = React.useState([]); //增加按钮函数 const btnClick = (e) => { if (tableLoadingStatus) { alert("表格数据尚未加载完成"); return false; columnsIndex++; let columnsObj = { dataIndex: "rcnr" + (columnsIndex), title: "日程内容" + columnsIndex, align: "left", className: "rcnrfontSet", width: 189.5, onCell: tdSet, render: rctd_render // 表格列新增一列 setColumns((arr => [...arr, columnsObj])); // 处理表格数据 setOptRecords((arr) => arr.map((item) => { return { ...item, ["rcnr" + columnsIndex]: { wz: columnsIndex - 1 } }; }表格列补齐在后端返回的数据中,如果有不存在的日程,直接连字段都没返回,这就造成了antd在渲染的时候列与表格数据不对应而引发的武发渲染的问题,于是我只能把所有数据遍历一遍,求出最大列长度,然后将列少的数据进行补全,由于添加数据时接口需要传当前点击的是哪一列,刚才补全的数据中是不包含wz字段的,因此我们需要再遍历一次数据,把wz字段加上去,代码如下:// 表格数据渲染函数 const tableDataRendering = function(res) { // 获取最大子节点的key数量 let maxChildLength = Object.keys(defaultData[0].children[0]).length; for (let i = 0; i < defaultData.length; i++) { for (let j = 0; j < defaultData[i].children.length; j++) { const currentObjLength = Object.keys(defaultData[i].children[j]).length; if (currentObjLength > maxChildLength) { maxChildLength = currentObjLength; // 补齐缺少的节点 for (let i = 0; i < defaultData.length; i++) { for (let j = 0; j < defaultData[i].children.length; j++) { const currentObjLength = Object.keys(defaultData[i].children[j]).length; // 当前节点的长度小于第一个子节点的长度就补齐 for (let k = currentObjLength; k < maxChildLength; k++) { defaultData[i].children[j]["rcnr" + k] = {}; // 如果存在空对象添加位置字段 for (let i = 0; i < defaultData.length; i++) { for (let j = 0; j < defaultData[i].children.length; j++) { // 获取每天的时间段对象 const item = defaultData[i].children[j]; // 获取所有的key const keys = Object.keys(item); // 提取所有的日程字段 for (let k = 1; k < keys.length; k++) { // 日程为空添加wz字段 if (Object.keys(item[keys[k]]).length <= 1) { defaultData[i].children[j][keys[k]].wz = k - 1; }监听子窗口关闭但点击单元格做完对应的操作后,弹窗关闭,此时我们需要在当前页面监听到子窗口关闭,然后向后台请求接口重新获取数据渲染页面,在打开的弹窗中提供了一个方法,可以调用父页面的方法,但是这个方法必须写在hooks外面他才能获取到。此时,问题就产生了,如果写在hooks外面,那么就无法拿到antd表格内部的数据做到页面重新渲染,经过一番思考后,想到了可以Proxy来实现,当被代理的对象发生改变时,就触发hooks里的代理函数,实现代码如下:<script type="text/babel"> // 声明代理变量 let pageStateEngineer; // 需要进行代理的对象 let pageState = { status: false }; // 监听子页面关闭,弹窗页面在关闭时可调用这个方法,触发页面刷新 const getSubpageData = (status) => { console.log("子页面关闭"); pageStateEngineer.status = true; const App = () => { // 代理处理函数 const pageStateHandler = { set: function(recObj, key, value) { // 表格状态改为正在加载 setTableLoadingStatus(true); // 重新请求接口,获取最新数据 axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', { }).then(function(res) { // 数据请求成功,改变表格加载层状态 setTableLoadingStatus(false); if (res.status === 200) { // 执行表格数据渲染函数 tableDataRendering(res); } else { alert("服务器错误"); // 修改对象属性 recObj[key] = value; return true; // 第一次渲染时,在借口调用成功后创建proxy React.useEffect(() => { // 调用接口获取表格数据 axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', { ls: 0, ts: 0 }).then(function(res) { //创建代理,监听pageState对象改变,pageStateHandler处理变更 pageStateEngineer = new Proxy(pageState, pageStateHandler); </script>重新渲染表格用户在使用日程表时,他会执行删除某个日程,此时表格渲染函数就要从columns和dataSource中各删除一条数据了,一开始我是直接覆盖其数据,这样做引用地址没变,就引发了动态增加列的那个bug,antd监听不到引用地址改变没有刷新页面。但是我又不知道用户具体删了哪条数据,不好自己写函数去处理。经过一番求助后,得到了三个解决方案:使用immer来解决这个问题,经过折腾后还是没实现,他返回的数组是只读的,antd无法对数据进行操作,故放弃。使用use-immer来替代React的useState来解决这个问题,这个就比较坑爹了,官方提供了umd的js库,但是通过cdn引入进来后,我硬是没找到它暴露出来的对象是哪个,没法用,故放弃。使用lodash的cloneDeep方法进行深拷贝让其引用地址改变,这样antd就能监听到数据改变,从而触发页面刷新。三个解决方案,经过验证后,只有第三个是可行的,于是我采取了它,实现代码如下:const App = () => { // 表格列格式定义 const defaultColumns = [ dataIndex: "rq", title: "日期", align: "center", fixed: "left", colSpan: 2, width: 140.5, className: "rqfontSet", onCell: dateHandle, render: (value, item, index) => {} dataIndex: "sjd", title: "时间段", width: 70, colSpan: 0, fixed: "left", align: "center", className: "sjdfontSet", render: (value, item, index) => { let v1 = value.charAt(0); let v2 = value.charAt(1); return <div>{v1}<br />{v2}</div>; // 表格数据渲染函数 const tableDataRendering = function(res) { // 根据日程列字段数据赋值表格列的日程字段,rcList中包含sjd所以需要1开始 for (let i = 1; i < rcList.length; i++) { let rcnr = { dataIndex: rcList[i], title: "日程内容" + i, align: "left", width: 189.5, className: "rcnrfontSet", onCell: tdSet, render: rctd_render defaultColumns.push(rcnr); // 渲染表格数据 handleData(defaultData); // 渲染表格列,使用cloneDeep进行深拷贝,触发useState的更新 setColumns(_.cloneDeep(defaultColumns)); // 计算要合并的列数 const handleData = (data) => { if (data == null) { data = defaultData; let newArr = []; data.map(item => { if (item.children) { item.children.forEach((subItem, i) => { let obj = { ...item }; Object.assign(obj, subItem); delete obj.children; obj.rowLength = item.children.length; newArr.push(obj); // console.log("处理好的表格数据"); // console.log(newArr); // 将处理好的数据放入optRecords,使用cloneDeep进行深拷贝,触发useState的更新 setOptRecords(_.cloneDeep(newArr)); }还有一种解决方案是使用JSON.parse进行深拷贝,但是这种深拷贝有个问题:但json数据中有函数时,里面的函数会失效没法执行,由于我需要自定义antd的表格,在json数据中包含了函数,因此我不能使用这个方法。触顶/触底加载数据由于业务需要,不能使用antd的分页功能,需要实现触顶向前加载30条数据,触底向后加载30条数据。总共只能加载3个月的数据。实现代码如下:<script type="text/babel"> // 触顶数据起始条数 let dataToppingStartNum = 0; // 触底数据起始条数 let dataBottomOutStartNum = 30; // 横向/垂直滚动条起始位置 let levelPosition; let verticalPosition; // 触底/触顶次数 let topFrequency = 0; let bottomFrequency = 0; const App = () => { // 横向滚动条位置 levelPosition = document.querySelector(".ant-table-body").scrollLeft; // 纵向滚动条位置 verticalPosition = document.querySelector(".ant-table-body").scrollTop; // 获取表格容器 let antdTable = document.querySelector(".ant-table-body"); //页面滚动监听 antdTable.onscroll = function() { // 触底向后加载数据 if (antdTable.scrollTop + antdTable.clientHeight >= antdTable.scrollHeight) { // 判断是否横向滚动 if (antdTable.scrollLeft !== levelPosition) { // 更新位置 levelPosition = antdTable.scrollLeft; return false; // 第一次触底不触发数据加载 if (bottomFrequency === 0) { bottomFrequency++; return false; if (bottomFrequency > 0) { bottomFrequency = 0; dataBottomOutStartNum += 30; // 判断已加载的数据 if (dataBottomOutStartNum > 90) { alert("最多只能向后加载90天的数据"); return false; // 保留向上滑动的天数 let bottomTS = 0; // 页面第一次向上滑动,修改位置 if (dataToppingStartNum !== 0) { bottomTS = -30; setTableLoadingStatus(true); axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', { ts: bottomTS, ls: dataBottomOutStartNum }).then(function(res) { // 数据请求成功,改变表格加载层状态 setTableLoadingStatus(false); if (res.status === 200) { // 执行表格数据渲染函数 tableDataRendering(res); } else { alert("服务器错误"); // 触顶向前加载数据 if (antdTable.scrollTop === 0) { // 判断是否横向滚动 if (antdTable.scrollLeft !== levelPosition) { // 更新位置 levelPosition = antdTable.scrollLeft; return false; // 第一次触顶不触发数据加载 if (topFrequency === 0) { topFrequency++; return false; if (topFrequency > 0) { topFrequency = 0; dataBottomOutStartNum += 30; if (dataBottomOutStartNum > 90) { alert("最多只能向前加载90天的数据"); return false; dataToppingStartNum -= 30; setTableLoadingStatus(true); axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', { ts: dataToppingStartNum, ls: dataBottomOutStartNum }).then(function(res) { // 数据请求成功,改变表格加载层状态 setTableLoadingStatus(false); if (res.status === 200) { // 执行表格数据渲染函数 tableDataRendering(res); } else { alert("服务器错误"); </script>这里需要比较坑的地方就是如果触顶/触底时,拖动横向滚动也会触发滚动监听,因此我们需要排除横向滚动事件。写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
插件重构前面我们把插件整体的读了一遍,接下来就可以用Vue3 + TypeScript来重构它了。作者的代码写的很精巧,逻辑方面不用做改动,我只是将它的代码实现从js改成了ts,修改了被Vue3废弃的写法,虽然做的修改比较简单,但是学到了作者的插件设计思想以及踩到的一些ts的坑,收获还算挺大。接下来,就跟大家分享下我的重构过程以及踩到的一些坑。安装依赖在用ts重构前,我们需要先安装相关依赖包,执行下述命令即可安装。yarn add typescript prettier eslint eslint-plugin-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser standard --dev随后,在项目根目录创建tsconfig.json文件,为typescript的配置文件,添加下述配置,设置"declaration": true即可在运行tsc命令时自动在types目录下生成声明文件。{ "exclude": [ "./node_modules" "compilerOptions": { "lib": [ "esnext", "dom" "baseUrl": "./", "outDir": "./dist/", // 打包到的目录 "target": "ES2015", // 转换成的目标语言 "module": "esnext", "declaration": true,// 是否生成声明文件 "declarationDir": "./dist/types/",// 声明文件打包的位置 "strict": true, // 开启严格模式 "sourceMap": true, // 便于浏览器调试 "moduleResolution": "node", // 使用node模块 "experimentalDecorators": true, // 使用装饰器 "skipLibCheck": true, // 跳过库检查 "esModuleInterop": true, // es模块互操作 "allowSyntheticDefaultImports": true, // 允许默认导入 "noImplicitAny": true, // 不能使用any "noImplicitThis": true, // 不能使用this "alwaysStrict": true, // 严格模式 "noUnusedLocals": true, // 不能有未使用的变量 "noUnusedParameters": true, // 不能有未使用的参数 "noImplicitReturns": true // 必须声明返回值 "include": [ "src/**/*.ts" ]// 要打包的文件 }修改已经废弃的语法在插件的入口文件Main.js中,插件需要向Vue全局挂载属性,即Vue.prototype.xx = xx,在vue3中这一写法已经废除,需要用app.config.globalProperties.xx = xx来替换,重构好的main.ts文件部分代码如下:import { App } from "vue"; export default { install(app: App, connection: string, opts: websocketOpts = { format: "" }): void { // ... 其它代码省略 ....// opts.$setInstance = (wsInstance: EventTarget) => { // 全局属性添加$socket app.config.globalProperties.$socket = wsInstance; }完整代码请移步:src/Main.tsbeforeDestroy生命周期被移除在插件的入口文件app.mixin中,组件销毁前它需要从全局移除已经添加在全局的属性,即beforeDestroy,在Vue3中这一写法已经被移除,需要用beforeUnmount来替换,其部分代码如下:import { App } from "vue"; export default { install(app: App, connection: string, opts: websocketOpts = { format: "" }): void { // .... 其它代码省略 ....// app.mixin({ beforeUnmount() { if (hasProxy) { const sockets = this.$options["sockets"]; if (sockets) { Object.keys(sockets).forEach((key) => { // 销毁前如果代理存在sockets存在则移除$options中给sockets添加过的key delete this.$options.sockets[key]; }扩展全局对象在Observer.ts中,需要向Websocket中添加sendObj方法,这在js中很简单,直接websocket.sendObj = ()=>{}即可。但是在ts中它就会报错,Websocket中不存在sendObj方法,一开始我想在lib.dom.d.ts中定义这个方法,但是想了想这样做不妥,不能修改全局的库声明文件,毕竟这是插件。 image-20201102210949765 经过我的一番折腾后,在ts的文档中找到了答案,ts的官方文档描述如下。 image-20201102210650833正如官方文档所描述,ts查找声明文件会从当前文件开始找,我们只需要在当前类中用declare global来扩展即可,代码如下:// 扩展全局对象 declare global { // 扩展websocket对象,添加sendObj方法 interface WebSocket { sendObj(obj: JSON): void; }添加上述代码后,报错就解决了,完整代码请移步:src/Observer.ts image-20201102211101120 回调函数类型定义在Emitter.ts文件里,添加监听的方法调用者可以传一个回调函数进去,这个回调函数的参数是未知的,因此就需要给他指定正确的类型,一开始我用的Function类型,但是eslint报错了,他不建议这么使用,报错如下: image-20201102212611648经过我的一番折腾后,找到了如下解决方案,声明类型时只需要将参数解构即可。addListener(label: T, callback: (...params: T[]) => void, vm: T): boolean { if (typeof callback === "function") { // label不存在就添加 this.listeners.has(label) || this.listeners.set(label, []); // 向label添加回调函数 this.listeners.get(label).push({ callback: callback, vm: vm }); return true; return false; }完整代码请移步:src/Emitter.ts验证插件能否正常工作插件重构完成后,我们将整个项目的文件复制到一个vue3项目的node_modules/vue-native-websocket下,替换原先的文件。 image-20201103001444839在main.ts中导入并使用插件。import { createApp } from "vue"; const app = createApp(App); // 使用VueNativeSock插件,并进行相关配置 .use(store) .use(router) .mount("#app"); // 使用VueNativeSock插件,并进行相关配置 app.use( VueNativeSock, `${base.lkWebSocket}/${localStorage.getItem("userID")}`, // 启用Vuex集成 store: store, // 数据发送/接收使用使用json format: "json", // 开启手动调用 connect() 连接服务器 connectManually: true, // 开启自动重连 reconnection: true, // 尝试重连的次数 reconnectionAttempts: 5, // 重连间隔时间 reconnectionDelay: 3000 );在组件中与websocket服务端建立连接mounted() { // 判断websocket是否连接: 当前为未连接状态并且本地存储中有userID !this.$store.state.socket.isConnected && localStorage.getItem("userID") !== null // 连接websocket服务器 this.$connect(`${base.lkWebSocket}/${localStorage.getItem("userID")}`); }调用sendObj方法来发送消息。this.$socket.sendObj({ msg: msgText, code: 0, username: this.$store.state.username, avatarSrc: this.$store.state.profilePicture, userID: this.$store.state.userID });调用onmessage方法来接收服务端消息。// 监听消息接收 this.$options.sockets.onmessage = (res: { data: string }) => { }完整代码请移步:chat-system,最终结果如下: image-20201103002555455给作者提个PR顺便给作者提个pr,将我修改的代码丢给作者😄vue-native-websocket/pulls image-20201103005547871发布至npm仓库至此,插件的重构就结束了,我们修改package.json中的build命令,替换为tsc,修改入口文件main以及类型声明文件入口types。部分呢代码如下,完整代码请移步:package.json{ "main": "dist/Main.js", "types": "dist/types/Main.d.ts", "scripts": { "build": "tsc" }随后,执行yarn run build命令,就会在项目的根目录下创建dist文件夹并将打包后的js文件放入其中。 image-20201102214629366dist目录中的文件就是我们要发布至npm仓库的包,在发布至npm仓库之前,我们要先做一些事情,让插件更加规范化。定义新版本推送规范我们在项目根目录创建PUBLISH.md文件,用于告知开发者修改本插件后如何进行推送。## 新版本推送规范 - 对插件进行修改 - 执行 `yarn build` 来生成打包后的文件 - 修改`package.json`中的版本号 - 提交你的修改 - 运行`package.json`中的`changelog`命令来生成更新记录 - 最后将项目推送到你的仓库,然后为主仓库创建一个Pull request编写插件使用文档作为一个插件,README.md文件是必不可少的,这个文件会告诉开发者如何使用这个插件,完整代码请移步:README.md定义提交规范无规矩不成方圆,插件亦是如此。我们需要通过一些工具来定义提交代码时规范,这样会使插件更易维护。安装依赖执行下述命令安装我们需要的插件包yarn global add commitizen上述命令会全局安装commitizen工具,它的作用是提供一个脚本工具给到开发者来按照指引生成符合规范的 commit 信息。执行下述命令,既可将其保存到package.json的依赖项,将config.commitizen配置添加到package.json的根目录,该配置告诉commitizen,当我们尝试提交此仓库时,我们实际上希望使用哪个适配器。commitizen init cz-conventional-changelog --save-dev --save-exact然后我们就可以通过git cz命令,来提交 git commit image-20201102221728435强制执行commit规范使用commitizen工具,我们可以通过执行git cz命令来提交符合规范的 commit 信息,但是在开发中,插件开发者不是通过命令行的方式来提交 commit 的,如果我们要强制校验其他人通过 vscode/webstorm 等其他工具的方式提交 commit,可以使用commitlint+husky的方式来配合使用。安装commitlint检查我们的 commit message 是否符合常规的提交格式,通过下述命令安装。yarn add @commitlint/config-conventional @commitlint/cli --dev在package.json中添加配置,指定提交规范,这里我们选用Angular 格式的配置"commitlint": { "extends": [ "@commitlint/config-conventional" },做完上述操作后,我们就可以验证命令提交的commit信息校验了,接下来我们来配合husky实现ide的commit校验,执行下述命令安装依赖包。yarn add husky --dev在package.json中添加commit-msg 的钩子,用于检查commitlint规范。"husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" }完成上述配置后,不管我们通过什么方式来提交 commit,如果 commit 信息不符合我们的规范,都会进行报错。自动生成CHANGELOG如果commit都符合刚才定义的Angular格式,那么发布新版本时, CHANGELOG 就可以用脚本自动生成。此处我们使用conventional-changelog-cli 工具来生成它,执行下述命令来安装依赖。yarn global add conventional-changelog-cli在项目根目录执行下述命令,即可生成CHANGELOG.md 文件:conventional-changelog -p angular -i CHANGELOG.md -s我们可以将上述命令配置进package.json中的scripts中,这样我们就可以通过yarn run changelog来生成了"scripts": { "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s" },生成的文件内容如下所示: image-20201102235321074插件发布最后,我们就可以将插件发布至npm仓库了。此处,重点内容在插件的重构,想从零开始学插件发布步骤的开发者可移步我的另一篇文章:Vue实现一个全屏加载插件并发布至npm仓库在终端进入项目根目录,执行下述命令,登录npm仓库,输入自己的用户名和密码npm login image-20201103003251083执行下属命令发布至npm仓库。npm publish --access public image-20201103003532065 插件发布成功,我们去npm仓库搜一下vue-native-websocket-vue3,如下所示,已经可以搜到了 image-20201103003826881npm仓库地址:vue-native-websocket-vue3最后,我们就可以在项目中使用yarn来安装使用了。 image-20201103004600660写在最后文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊本文首发于掘金,未经许可禁止转载💌
前言前几天我用Vue3重构了我那个Vue2的开源项目,最后还遗留了一个问题:项目中用的一个websocket插件还不能正常使用。于是,我决定重写这个插,让其支持Vue3。本文将记录下重写这个插件的过程并将其发布至npm仓库,顺便给插件作者提个PR,欢迎各位感兴趣的开发者阅读本文。插件解读 image-20201103005333494如上图所示就是即将要重构的插件,目前有735个star,我们先将插件代码clone到本地。git clone https://github.com/nathantsoi/vue-native-websocket下载到本地后,用你喜欢的ide打开它,其目录如下: image-20201101194150523目录解读经过一番梳理后,其各个目录的作用如下:vue-native-websocket 项目文件夹Emitter.js websocket的事件队列与分发的实现Main.js vue 插件入口代码Observer.js 观察者模式,websocket服务核心功能封装build.js 编译后的代码文件dist 编译后的项目文件夹node_modules 项目依赖库src 项目源码文件夹test 单元测试文件.eslintrc.json 项目的eslint配置.gitignore 上传至git仓库需要忽略的文件.nvmrc 指定项目期望用的node版本.travis.yml 自动化构建配置文件CHANGELOG.md 版本发布记录文件npm-shrinkwrap.json npm包版本锁定文件package.json 项目依赖配置文件PUBLISH.md 修改完插件后的发布规范README.md 插件使用文档webpack.config.js webpack配置文件yarn.lock yarn包版本锁定文件读完代码后,我们发现他的实现逻辑很精简,一个字:妙。该插件的核心代码就src目录下的3个文件,接下来我们就从插件的入口文件Main.js开始解读。如下所示,它引入了两个文件以及Vue官方要求的插件作为一个对象时必须提供的install方法。import Observer from './Observer' import Emitter from './Emitter' export default { install (Vue, connection, opts = {}) { // ... 其它代码省略 ... // }那么,我们就先来看看第一个引入的文件Observer.js的代码。如下所示,它引入了Emitter.js文件,以及它自身的实现代码。import Emitter from './Emitter' export default class { constructor (connectionUrl, opts = {}) { // ... 其它代码省略... // }Emitter.js同样的,我们先从他引入的文件开始读,即Emitter.js,其代码如下,我读完代码后并添加了相关注释,它实现了一个事件监听队列,以及一个事件触发函数emitclass Emitter { constructor () { this.listeners = new Map() * 添加事件监听 * @param label 事件名称 * @param callback 回调函数 * @param vm this对象 * @return {boolean} addListener (label, callback, vm) { if (typeof callback === 'function') { // label不存在就添加 this.listeners.has(label) || this.listeners.set(label, []) // 向label添加回调函数 this.listeners.get(label).push({callback: callback, vm: vm}) return true return false * 移除监听 * @param label 事件名称 * @param callback 回调函数 * @param vm this对象 * @return {boolean} removeListener (label, callback, vm) { // 从监听列表中获取当前事件 let listeners = this.listeners.get(label) let index if (listeners && listeners.length) { // 寻找当前事件在事件监听列表的位置 index = listeners.reduce((i, listener, index) => { if (typeof listener.callback === 'function' && listener.callback === callback && listener.vm === vm) { i = index return i }, -1) if (index > -1) { // 移除事件 listeners.splice(index, 1) this.listeners.set(label, listeners) return true return false * 触发监听 * @param label 事件名称 * @param args 参数 * @return {boolean} emit (label, ...args) { // 获取事件列表中存储的事件 let listeners = this.listeners.get(label) if (listeners && listeners.length) { listeners.forEach((listener) => { // 扩展callback函数,让其拥有listener.vm中的方法 listener.callback.call(listener.vm, ...args) return true return false export default new Emitter()Observer.js接下来,我们在回过头来看Observer.js的代码,他实现了websocket服务核心功能的封装,是这个插件的核心。它的constructor部分代码如下所示,他定义了插件调用者可以传的参数以及初始值。constructor (connectionUrl, opts = {}) { // 获取参数中的format并将其转成小写 this.format = opts.format && opts.format.toLowerCase() // 如果url以//开始对其进行处理添加正确的websocket协议前缀 if (connectionUrl.startsWith('//')) { // 当前网站如果为https请求则添加wss前缀否则添加ws前缀 const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws' connectionUrl = `${scheme}:${connectionUrl}` // 将处理好的url和opts赋值给当前类内部变量 this.connectionUrl = connectionUrl this.opts = opts // 是否开启重连,默认值为false this.reconnection = this.opts.reconnection || false // 最大重连次数,默认值为无穷大 this.reconnectionAttempts = this.opts.reconnectionAttempts || Infinity // 重连间隔时间,默认为1s this.reconnectionDelay = this.opts.reconnectionDelay || 1000 // 重连超时id,默认为0 this.reconnectTimeoutId = 0 // 已重连次数,默认为0 this.reconnectionCount = 0 // 传输数据时的处理函数 this.passToStoreHandler = this.opts.passToStoreHandler || false // 建立连接 this.connect(connectionUrl, opts) // 如果配置参数中有传store就将store赋值 if (opts.store) { this.store = opts.store } // 如果配置参数中有传vuex的同步处理函数就将mutations赋值 if (opts.mutations) { this.mutations = opts.mutations } // 事件触发 this.onEvent() }连接函数我们再来看看connet方法的实现,它的代码如下,它会根据用户传入的websocket服务端地址以及插件参数来建立websocket连接。// 连接websocket connect (connectionUrl, opts = {}) { // 获取配置参数传入的协议 let protocol = opts.protocol || '' // 如果没传协议就建立一个正常的websocket连接否则就创建带协议的websocket连接 this.WebSocket = opts.WebSocket || (protocol === '' ? new WebSocket(connectionUrl) : new WebSocket(connectionUrl, protocol)) // 启用json发送 if (this.format === 'json') { // 如果websocket中没有senObj就添加这个方法对象 if (!('sendObj' in this.WebSocket)) { // 将发送的消息转为json字符串 this.WebSocket.sendObj = (obj) => this.WebSocket.send(JSON.stringify(obj)) return this.WebSocket }重连函数我们再来看看reconnect方法的实现,它的代码如下,它会读取用户传进来的最大重连次数,然后重新与websocket服务端建立链接。// 重新连接 reconnect () { // 已重连次数小于等于设置的连接次数时执行重连 if (this.reconnectionCount <= this.reconnectionAttempts) { this.reconnectionCount++ // 清理上一次重连时的定时器 clearTimeout(this.reconnectTimeoutId) // 开始重连 this.reconnectTimeoutId = setTimeout(() => { // 如果启用vuex就触发vuex中的重连方法 if (this.store) { this.passToStore('SOCKET_RECONNECT', this.reconnectionCount) } // 重新连接 this.connect(this.connectionUrl, this.opts) // 触发WebSocket事件 this.onEvent() }, this.reconnectionDelay) } else { if (this.store) { // 如果启用vuex则触发重连失败方法 this.passToStore('SOCKET_RECONNECT_ERROR', true) } }事件触发函数我们再来看看onEvent函数,它的实现代码如下,它会调用Emitter中的emit方法,对websocket中的4个监听事件进行分发扩展,交由Emitter类来管理。// 事件分发 onEvent () { ['onmessage', 'onclose', 'onerror', 'onopen'].forEach((eventType) => { this.WebSocket[eventType] = (event) => { Emitter.emit(eventType, event) // 调用vuex中对应的方法 if (this.store) { this.passToStore('SOCKET_' + eventType, event) } // 处于重新连接状态切事件为onopen时执行 if (this.reconnection && eventType === 'onopen') { // 设置实例 this.opts.$setInstance(event.currentTarget) // 清空重连次数 this.reconnectionCount = 0 // 如果处于重连状态且事件为onclose时调用重连方法 if (this.reconnection && eventType === 'onclose') { this.reconnect() } }vuex事件处理函数我们再来看看处理vuex事件的实现函数,它的实现代码如下,它用于触发vuex中的方法,它允许调用者传passToStoreHandler事件处理函数,用于触发前的事件处理。/** * 触发vuex中的方法 * @param eventName 事件名称 * @param event 事件 passToStore (eventName, event) { // 如果参数中有传事件处理函数则执行自定义的事件处理函数,否则执行默认的处理函数 if (this.passToStoreHandler) { this.passToStoreHandler(eventName, event, this.defaultPassToStore.bind(this)) } else { this.defaultPassToStore(eventName, event) * 默认的事件处理函数 * @param eventName 事件名称 * @param event 事件 defaultPassToStore (eventName, event) { // 事件名称开头不是SOCKET_则终止函数 if (!eventName.startsWith('SOCKET_')) { return } let method = 'commit' // 事件名称字母转大写 let target = eventName.toUpperCase() // 消息内容 let msg = event // data存在且数据为json格式 if (this.format === 'json' && event.data) { // 将data从json字符串转为json对象 msg = JSON.parse(event.data) // 判断msg是同步还是异步 if (msg.mutation) { target = [msg.namespace || '', msg.mutation].filter((e) => !!e).join('/') } else if (msg.action) { method = 'dispatch' target = [msg.namespace || '', msg.action].filter((e) => !!e).join('/') if (this.mutations) { target = this.mutations[target] || target // 触发store中的方法 this.store[method](target, msg) }Main.js上面我们读完了插件的核心实现代码,最后我们来看看插件的入口文件,它的代码如下,他会将我们前面实现的websocket相关封装应用到Vue全局。他做了以下事情:全局挂载$socket属性,便于访问socket建立的socket连接启用手动连接时,向全局挂载手动连接方法和关闭连接方法全局混入,添加socket事件监听,组件销毁前移除全局添加的方法import Observer from './Observer' import Emitter from './Emitter' export default { install (Vue, connection, opts = {}) { // 没有传入连接,抛出异常 if (!connection) { throw new Error('[vue-native-socket] cannot locate connection') } let observer = null opts.$setInstance = (wsInstance) => { // 全局属性添加$socket Vue.prototype.$socket = wsInstance // 配置选项中启用手动连接 if (opts.connectManually) { Vue.prototype.$connect = (connectionUrl = connection, connectionOpts = opts) => { // 调用者传入的参数中添加set实例 connectionOpts.$setInstance = opts.$setInstance // 创建Observer建立websocket连接 observer = new Observer(connectionUrl, connectionOpts) // 全局添加$socket Vue.prototype.$socket = observer.WebSocket // 全局添加连接断开处理函数 Vue.prototype.$disconnect = () => { if (observer && observer.reconnection) { // 重新连接状态改为false observer.reconnection = false // 如果全局属性socket存在则从全局属性移除 if (Vue.prototype.$socket) { // 关闭连接 Vue.prototype.$socket.close() delete Vue.prototype.$socket } else { // 未启用手动连接 observer = new Observer(connection, opts) // 全局添加$socket属性,连接至websocket服务器 Vue.prototype.$socket = observer.WebSocket const hasProxy = typeof Proxy !== 'undefined' && typeof Proxy === 'function' && /native code/.test(Proxy.toString()) Vue.mixin({ created () { let vm = this let sockets = this.$options['sockets'] if (hasProxy) { this.$options.sockets = new Proxy({}, { set (target, key, value) { // 添加监听 Emitter.addListener(key, value, vm) target[key] = value return true deleteProperty (target, key) { // 移除监听 Emitter.removeListener(key, vm.$options.sockets[key], vm) delete target.key return true if (sockets) { Object.keys(sockets).forEach((key) => { // 给$options中添加sockets中的key this.$options.sockets[key] = sockets[key] } else { // 将对象密封,不能再进行改变 Object.seal(this.$options.sockets) // if !hasProxy need addListener if (sockets) { Object.keys(sockets).forEach(key => { // 添加监听 Emitter.addListener(key, sockets[key], vm) beforeDestroy () { if (hasProxy) { let sockets = this.$options['sockets'] if (sockets) { Object.keys(sockets).forEach((key) => { // 销毁前如果代理存在sockets存在则移除$options中给sockets添加过的key delete this.$options.sockets[key]
前言2020年9月18日,vue3正式版发布了,前几天把文档整体读了一遍,感触很深,可以解决我项目中的一些痛点,于是就决定重构之前那个vue2的开源项目。本篇文章就记录下重构vue2项目的过程,欢迎各位感兴趣的开发者阅读本文。环境搭建本来打算使用vite + vue3 + VueRouter + vuex + typescript来构架项目的,但是经过一番折腾后发现vite目前只对vue支持,对于vue周边的一些库还没做到支持,没法在项目中使用。最后,还是决定使用Vue Cli 4.5来构建了。虽然vite目前还无法正常在项目中使用,但是我也折腾了一回,就记录下在折腾时的过程以及一些报错。使用vite构建项目本文采用的包管理工具为yarn,将其升级至最新版本就可以正常创建vite项目了。初始化项目接下来,我们来看看具体步骤。打开终端,进入你的项目目录,运行命令:yarn crete vite-app vite-project,该命令用于创建一个名为vite-project的项目。创建完成后,会得到如下所示的文件。进入创建好的项目,运行命令:yarn install,该命令会安装package.json中声明的依赖。我们使用IDE打开刚才创建的项目,整体项目如下所示,vite官方为我们提供了一个简单的demo。打开package.json查看启动命令在终端运行命令:yarn run dev或者点击ide的运行图标来启动项目。大功告成,浏览器访问 http://localhost:3000/,如下所示。集成Vue周边库我们将Vue CLI初始化的项目文件替换到用vite初始化的项目中去,然后修改packge.json中的相关依赖,然后重新安装依赖即可。具体过程如下:替换文件,替换后的项目目录如下所示。从package.json中提取我们需要的依赖,提取后的文件下。{ "name": "vite-project", "version": "0.1.0", "scripts": { "dev": "vite", "build": "vite build" "dependencies": { "core-js": "^3.6.5", "vue": "^3.0.0-0", "vue-class-component": "^8.0.0-0", "vue-router": "^4.0.0-0", "vuex": "^4.0.0-0" "devDependencies": { "vite": "^1.0.0-rc.1", "@typescript-eslint/eslint-plugin": "^2.33.0", "@typescript-eslint/parser": "^2.33.0", "@vue/compiler-sfc": "^3.0.0-0", "@vue/eslint-config-prettier": "^6.0.0", "@vue/eslint-config-typescript": "^5.0.2", "eslint": "^6.7.2", "eslint-plugin-prettier": "^3.1.3", "eslint-plugin-vue": "^7.0.0-0", "node-sass": "^4.12.0", "prettier": "^1.19.1", "sass-loader": "^8.0.2", "typescript": "~3.9.3" "license": "MIT" }8abcc9f5b934568e54c0229c6663866c启动项目,没报错,嘴角疯狂上扬。浏览器访问后,空白页面,打开console后,发现main.js 404难搞,找不到main.js,那我把main.ts后缀改一下试试。将后缀改成js后,文件是不报错404了,但是又有了新的错误。vite服务500和@别名无法识别,于是我打开ide的控制台看了错误,大概是scss的错,vite还没支持scss。scss不支持,别名不识别,网上找了一圈也没找到解决方案,这些最基础的东西都无法被vite支持,那它就不能用在项目中了,于是我放弃了。综合上述,vite要走的路还有很多,等它在社区成熟了,再将它应用到项目中吧。使用Vue Cli构建项目由于vite的不合适,我们还是继续选择用webpack,此处我们选择用Vue CLI 4.5来创建项目。初始化项目在终端进入项目目录,执行命令:vue create chat-system-vue3该命令用于创建一个名为chat-system-vue3的项目。创建完成后,如下所示。用IDE打开项目,打开package.json文件,查看项目启动命令或者直接点编译器的运行按钮。OK,大功告成,打开浏览器,访问终端的内网地址。解决报错问题在浏览CLI默认创建的demo时,打开main.js文件发现其中App.vue文件报类型错误,无法推导出具体的类型。一开始,我也懵逼,想起了Vue文档所说的,启用TypeScript必须要让 TypeScript 正确推断 Vue 组件选项中的类型,需要使用 defineComponent。App.vue文件代码如下:<template> <div id="nav"> <router-link to="/">Home</router-link> | <router-link to="/about">About</router-link> </div> <router-view /> </template> <style lang="scss"> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; #nav { padding: 30px; font-weight: bold; color: #2c3e50; &.router-link-exact-active { color: #42b983; </style>观察代码后我们发现CLI生成的代码没有包含文档中所描述的代码,因此我们将其补充上,然后导出即可。import { defineComponent } from "vue"; const Component = defineComponent({ // 已启用类型推断 export default Component;加入上述代码后,我们的代码就不报错了。根据官网描述,我们可以在defineComponent的包裹中写组件的逻辑代码,但是我看了CIL提供的demo的Home组件后发现,他的写法如下。export default class Home extends Vue {}在项目的src目录下有一个名为shims-vue.d.ts的文件,它声明了所有vue文件的返回类型,因此我们可以按照上述方法来写。该声明文件代码如下。declare module "*.vue" { import { defineComponent } from "vue"; const component: ReturnType<typeof defineComponent>; export default component; }这样的写法看起来更符合TypeScript,不过这种写法写法只支持部分属性,同样的我们组件的逻辑代码写在类内部即可,那么将刚才App.vue文件中做的更改也应用到此处,如下所示。<script lang="ts"> import { Vue } from "vue-class-component"; export default class App extends Vue {} </script>class写法支持的属性如下图所示: image-20201009210815033配置IDE此处内容仅适用于webstorm,如果编辑器是其他的可跳过本部分。我们在项目中集成了eslint和prettier,默认情况下webstorm是没有启用这两个东西的,需要我们自己手动开启。打开webstorm的配置菜单,如下所示 image-20201006153458084搜索eslint,按照下图所示进行配置,配置完成后点APPLY、OK即可。 image-20201006153031544搜索prettier,按照下图所示进行配置,配置完成后点APPLY、OK即可。 image-20201006153654226配置完上面的内容后,还有一个问题,在组件上用v-if v-for等vue指令时没有提示,这是因为webstorm没法正确读取node_modules包,按照下述操作即可解决这一问题。 image-20201006154114315执行上述操作后,等待时间根据cpu性能而定,届时电脑会发热。这都是正常现象 image-20201006154306682成功后,我们发现编辑器已经可以正常识别v-指令了,并且给了相应的提示。 image-20201006154454592项目目录对比按照上述步骤,即可创建一个vue3的项目,接下来我们将需要重构的vue2项目的目录与上面创建的项目进行下目录对比。如下所示,为vue2.0项目的目录 image-20201006162826706如下所示,为vue3.0项目的目录 image-20201006162936370仔细观察后,我们发现在目录上并没有什么大的区别,只是多了typescript的配置文件和项目内使用ts的时辅助文件。项目重构接下来,我们来一步步把vue2项目的文件迁移到vue3项目中,修改不合适的地方,让其适配vue3.0。适配路由配置我们先从路由配置文件开始适配,打开vue3项目的router/index.ts文件,发现有一个报错,报错如下。 image-20201006215331894错误信息是类型没被推导出来,我看了下面路由的写法后,盲猜它需要用函数返回,于是试了下,还真就是这样,正确的路由写法如下。{ path: "/", name: "Home", component: () => Home }整体的路由配置文件代码如下:import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router"; import Home from "../views/Home.vue"; const routes: Array<RouteRecordRaw> = [ path: "/", name: "Home", component: () => Home path: "/about", name: "About", // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ "../views/About.vue") const router = createRouter({ history: createWebHashHistory(), routes export default router;我们再来看看vue2项目中的路由配置,为了简单起见我摘抄了部分代码过来,如下所示。import Vue from 'vue' import VueRouter from 'vue-router' import MsgList from '../views/msg-list' import Login from "../views/login" import MainBody from '../components/main-body' Vue.use(VueRouter); const routes = [ path: '/', redirect: '/contents/message/message', name: 'contents', path: '/contents/:thisStatus', // 重定向到嵌套路由 redirect: '/contents/:thisStatus/:thisStatus/', components: { mainArea: MainBody props: { mainArea: true children: [ path: 'message', components: { msgList: MsgList name: 'login', path: "/login", components: { login:Login const router = new VueRouter({ // mode: 'history', routes, export default router经过观察后,它们的不同点如下:Vue.use(VueRouter)这种写法被移除new VueRouter({})写法改为了createRouter({})hash模式和history模式声明由原先的mode选项变更为了createWebHashHistory()和createWebHistory()更加语义化了声明路由时多了ts的类型注解Array<RouteRecordRaw>知道它们的区别后,我们就可以对路由进行适配和迁移了,迁移完成的路由配置文件:router/index.ts这里有个小坑,路由懒加载的时候必须给他返回一个函数。例如:component: () => import("../views/msg-list.vue")。不然就会报黄色警告。 image-20201015223425458 image-20201015223525227适配Vuex配置接下来我们来看看两个版本在vuex使用上的区别,如下所示为vue3的vuex配置。import { createStore } from "vuex"; export default createStore({ state: {}, mutations: {}, actions: {}, modules: {} });我们再来看看vue2项目中的vuex配置,为了简洁起见,我只列出了大体代码。import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex); export default new Vuex.Store({ state: { mutations: { actions: { modules: { })经过对比后,我们发现的不同点如下所示:按需导入import { createStore } from "vuex",移除了之前的整个导入import Vuex from 'vuex'移除了Vue.use(Vuex)的写法导出时丢弃之前的new Vuex.Store写法,改用了createStore写法。知道上述不同点后,我们就可以对代码进行适配和迁移了,迁移完成的vuex配置文件:store/index.ts
如果需要在vue的原型上挂载东西,就不能使用以前的原型挂载方法,需要使用新方法config.globalProperties,详细用法请查阅官方文档。我的项目中用到了一个websocket的插件,他需要在vuex中往Vue原型上挂载方法,下面是我的做法。将main.ts中的createApp方法导出。import { createApp } from "vue"; const app = createApp(App); export default app; 在store/index.ts中导入main.ts,然后调用方法挂载即可。mutations: { // 连接打开 SOCKET_ONOPEN(state, event) { main.config.globalProperties.$socket = event.currentTarget; state.socket.isConnected = true; // 连接成功时启动定时发送心跳消息,避免被服务器断开连接 state.socket.heartBeatTimer = setInterval(() => { const message = "心跳消息"; state.socket.isConnected && main.config.globalProperties.$socket.sendObj({ code: 200, msg: message }, state.socket.heartBeatInterval); }适配axiosaxios在封装成插件时与之前的差别对比如下:暴露install方法由原来的Plugin.install改为了install增加了ts的类型声明Object.defineProperties舍弃了,现在直接使用app.config.globalProperties挂载即可适配完成的代码如下:import { App } from "vue"; import axiosObj, { AxiosInstance, AxiosRequestConfig } from "axios"; import store from "../store/index"; const defaultConfig = { // baseURL在此处省略配置,考虑到项目可能由多人协作完成开发,域名也各不相同,此处通过对api的抽离,域名单独配置在base.js中 // 请求超时时间 timeout: 60 * 1000, // 跨域请求时是否需要凭证 // withCredentials: true, // Check cross-site Access-Control heards: { get: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" // 将普适性的请求头作为基础配置。当需要特殊请求头时,将特殊请求头作为参数传入,覆盖基础配置 post: { "Content-Type": "application/json;charset=utf-8" // 将普适性的请求头作为基础配置。当需要特殊请求头时,将特殊请求头作为参数传入,覆盖基础配置 * 请求失败后的错误统一处理,当然还有更多状态码判断,根据自己业务需求去扩展即可 * @param status 请求失败的状态码 * @param msg 错误信息 const errorHandle = (status: number, msg: string) => { // 状态码判断 switch (status) { // 401: 未登录状态,跳转登录页 case 401: // 跳转登录页 break; // 403 token过期 case 403: // 如果不需要自动刷新token,可以在这里移除本地存储中的token,跳转登录页 break; // 404请求不存在 case 404: // 提示资源不存在 break; default: console.log(msg); export default { // 暴露安装方法 install(app: App, config: AxiosRequestConfig = defaultConfig) { let _axios: AxiosInstance; // 创建实例 _axios = axiosObj.create(config); // 请求拦截器 _axios.interceptors.request.use( function(config) { // 从vuex里获取token const token = store.state.token; // 如果token存在就在请求头里添加 token && (config.headers.token = token); return config; function(error) { // Do something with request error error.data = {}; error.data.msg = "服务器异常"; return Promise.reject(error); // 响应拦截器 _axios.interceptors.response.use( function(response) { // 清除本地存储中的token,如果需要刷新token,在这里通过旧的token跟服务器换新token,将新的token设置的vuex中 if (response.data.code === 401) { localStorage.removeItem("token"); // 页面刷新 parent.location.reload(); // 只返回response中的data数据 return response.data; function(error) { if (error) { // 请求已发出,但不在2xx范围内 errorHandle(error.status, error.data.msg); return Promise.reject(error); } else { // 断网 return Promise.reject(error); // 将axios挂载到vue的全局属性中 app.config.globalProperties.$axios = _axios; };然后将其在main.js中use,就可以在代码中通过this.$axios.xx来使用了。不过上述将axios挂载到vue上是多此一举的,因为我已经将api进行了抽离,在每个单独的api文件中都是通过导入我们封装好的axios的配置文件,然后用导入进来的axios实例来进行的接口封装。(ps: 之前由于自己太菜没注意到这个,傻傻的将其封装成了插件😂)那么,不需要将其封装成插件的话,那它就属于对axios进行配置封装了,我们将它放在config目录下,将上述代码稍作修改即可,修改好的代码地址:config/axios.ts。最后在main.ts中将api挂载到全局属性。import { createApp } from "vue"; import api from "./api/index"; const app = createApp(App); app.config.globalProperties.$api = api;随后就就可以在业务代码中通过this.$api.xx按模块来调用我们抛出来的接口了。shims-vue.d.ts类型声明文件shims-vue.d.ts是一个Typescript的声明文件,当项目启用ts后,有些文件是我们自己封装的,类型较为复杂,ts不能推导出其具体类型,此时就需要我们进行手动声明。例如上面我们挂载到原型上的$api,它导出了一个类文件,此时类型就较为复杂了,ts没法推导出其类型,我们在使用时就会报错。 image-20201010100416381要解决这个错误,我们就需要在shims-vue.d.ts中声明api的的类型// 声明全局属性类型 declare module "@vue/runtime-core" { interface ComponentCustomProperties<T> { $api: T; }注意:在shims-vue.d.ts文件中,类型声明超过1个时,组件内需要import包就不能在其内部进行,需要将其写在最外层,否则会报错。 image-20201010101906448 适配入口文件由于启用了typescript,入口文件由main.js变成了main.ts,文件中的写法与之前相比其不同点如下:初始化挂载vue由原先的new Vue(App)改为了按需导入写法的createApp(App)使用插件时,也由原先的Vue.use()改成了,createApp(App).use()在我的项目中引用了几个插件,需要在入口文件中做一些初始化的操作,插件还是2.x版本,没有ts的类型声明文件,因此导入时ts没法推导出它的类型,就得用// @ts-ignore让ts忽略它。完整的入口文件地址:main.ts适配组件基础设施完善后,接下来我们来适配组件,我们先来试试把2.x项目的所有组件搬过来看看,能不能直接启动。结果可想而知,无法运行。因为我用了2.x的插件,vue3.0有关插件的封装,一些写法变了。我项目中总共引用了2个插件v-viewer、vue-native-websocket,v-viewer这个插件无解,他底层使用用到的2.x语法太多了,所以我选择放弃这个插件。vue-native-websocket这个插件就是使用的Vue.prototype.xx写法被舍弃了,用新的写法Vue.config.globalProperties.xx将其替换即可。 image-20201009174402912 替换完成后,重新编译即可,随后启动项目,如下所示,错误解决,项目成功启动。 image-20201009175415170正如上图中所看到的,控制台有黄色警告,因为我们组件的代码还是使用的vue2.x的语法,我们要重新整理组件中的方法从而适配vue3.0。注意:组件script标签声明lang="ts"后,就必须按照Vue官方文档所说使用defineComponent全局方法来定义组件。组件优化接下来,我们从login.vue组件开始重构,看看都做了哪些优化。创建type文件夹,文件夹内创建ComponentDataType.ts,将组件中用到的类型指定放在其中。创建enum文件夹,将组件中用到的枚举放在其中。我们先来看看第一点,将组件内用到的类型进行统一管理,我们以登录组件为例,我们需要为data返回的对象指定其每个属性的类型,因此我们ComponentDataType.ts中创建一个名为loginDataType的类型,其代码如下。export type loginDataType<T> = { loginUndo: T; // 禁止登录时的图标 loginBtnNormal: T; // 登录时的按钮图标 loginBtnHover: T; // 鼠标悬浮时的登录图标 loginBtnDown: T; // 鼠标按下时的登录图标 userName: string; // 用户名 password: string; // 密码 confirmPassword: string; // 注册时的确认登录密码 isLoginStatus: number; // 登录状态:0.未登录 1.登录中 2.注册 loginStatusEnum: Object; // 登录状态枚举 isDefaultAvatar: boolean; // 头像是否为默认头像 avatarSrc: T; // 头像地址 loadText: string; // 加载层的文字 };声明好类型后,就可以在组件中使用了,代码如下:import { loginDataType } from "@/type/ComponentDataType"; export default defineComponent({ data<T>(): loginDataType<T> { return { loginUndo: require("../assets/img/login/icon-enter-undo@2x.png"), loginBtnNormal: require("../assets/img/login/icon-enter-undo@2x.png"), loginBtnHover: require("../assets/img/login/icon-enter-hover@2x.png"), loginBtnDown: require("../assets/img/login/icon-enter-down@2x.png"), userName: "", password: "", confirmPassword: "", isLoginStatus: 0, loginStatusEnum: loginStatusEnum, isDefaultAvatar: true, avatarSrc: require("../assets/img/login/LoginWindow_BigDefaultHeadImage@2x.png"), loadText: "上传中" })上述代码完整地址:type/ComponentDataType.tslogin.vue再然后,我们看看第二点,使用enum来优化组件内部的条件判断,例如上面data中的isLoginStatus就有3种状态,我们要根据这三种状态来做不同的事情,如果直接用数字来代表三种状态直接赋值数字,后期维护时将是一件很痛苦的事情,如果用enum来定义的话,根据语意一眼就能看出它的状态是什么。我们在enum文件夹中创建ComponentEnum.ts文件,组件内用到的所有枚举都会在此文件内定义,接下来在组件内创建loginStatusEnum,代码如下:export enum loginStatusEnum { NOT_LOGGED_IN = 0, // 未登录 LOGGING_IN = 1, // 登录中 REGISTERED = 2 // 注册 }声明好后,我们就可以在组件中使用了,代码如下:import { loginStatusEnum } from "@/enum/ComponentEnum"; export default defineComponent({ methods: { stateSwitching: function(status) { case "条件1": this.isLoginStatus = loginStatusEnum.LOGGING_IN; break; case "条件2": this.isLoginStatus = loginStatusEnum.NOT_LOGGED_IN; break; })上述代码完整地址:enum/ComponentEnum.tslogin.vuethis指向在适配组件过程中,方法内部的this不能很好的识别,无奈就用了很笨的方法解决。如下所示:const _img = new Image(); _img.src = base64; _img.onload = function() { const _canvas = document.createElement("canvas"); const w = this.width / scale; const h = this.height / scale; _canvas.setAttribute("width", w + ""); _canvas.setAttribute("height", h + ""); _canvas.getContext("2d")?.drawImage(this, 0, 0, w, h); const base64 = _canvas.toDataURL("image/jpeg"); }onload方法内部的this应该是指向_img的,但是ts并不这么认为,报错如下所示。 image-20201013171520088this对象中不包含width属性,解决方案就是讲this换成_img,问题解决。 image-20201013171712449Dom对象类型定义当操作dom对象时,层级过时ts就无法推断出具体类型了,如下所示:sendMessage: function(event: KeyboardEvent) { if (event.key === "Enter") { // 阻止编辑框默认生成div事件 event.preventDefault(); let msgText = ""; // 获取输入框下的所有子元素 const allNodes = event.target.childNodes; for (const item of allNodes) { // 判断当前元素是否为img元素 if (item.nodeName === "IMG") { if (item.alt === "") { // 是图片 let base64Img = item.src; // 删除base64图片的前缀 base64Img = base64Img.replace(/^data:image\/\w+;base64,/, ""); //随机文件名 const fileName = new Date().getTime() + "chatImg" + ".jpeg"; //将base64转换成file const imgFile = this.convertBase64UrlToImgFile( base64Img, fileName, "image/jpeg" }上面为一个发送消息的函数的部分代码,消息框中包含图片和文字,要对图片进行单独处理,我们需要要从target中拿到所有节点childNodes,然后遍历每个节点获取其类型,childNodes的类型为NodeList,那么他的每一个元素就是Node类型,如果当前遍历到的元素的nodeName属性是IMG时,它就是一个图片,我们就获取它的alt属性进一步判断,再获取src属性。然而,ts会报错alt和src属性不存在,报错如下: image-20201013172815950此时,我们就需要把item断言成HTMLImageElement类型。 image-20201019110053258复杂类型定义在适配组件过程中,遇到一个比较复杂的数据类型定义,数据如下:data(){ return { friendsList: [ groupName: "我", totalPeople: 2, onlineUsers: 2, friendsData: [ username: "神奇的程序员", avatarSrc: "https://www.kaisir.cn/uploads/1ece3749801d4d45933ba8b31403c685touxiang.jpeg", signature: "今天的努力只为未来", onlineStatus: true, userId: "c04618bab36146e3a9d3b411e7f9eb8f" username: "admin", avatarSrc: "https://www.kaisir.cn/uploads/40ba319f75964c25a7370e3909d347c5admin.jpg", signature: "", onlineStatus: true, userId: "32ee06c8380e479b9cd4097e170a6193" groupName: "我的朋友", totalPeople: 0, onlineUsers: 0, friendsData: [] groupName: "我的家人", totalPeople: 0, onlineUsers: 0, friendsData: [] groupName: "我的同事", totalPeople: 0, onlineUsers: 0, friendsData: [] },一开始我是这样定义的。 image-20201014214430066嵌套到一起,自认为没问题,放进代码后,报错长度不匹配,这样写知识给第一个对象定义了类型。 image-20201014214529652经过一番求助后,他们说应该分开写,不能这样嵌套定义,正确写法如下:类型分开定义// 联系人面板Data属性定义 export type contactListDataType<V> = { friendsList: Array<V>; // 联系人列表类型定义 export type friendsListType<V> = { groupName: string; // 分组名称 totalPeople: number; // 总人数 onlineUsers: number; // 在线人数 friendsData: Array<V>; // 好友列表 // 联系人类型定义 export type friendsDataType = { username: string; // 昵称 avatarSrc: string; // 头像地址 signature: string; // 个性签名 onlineStatus: boolean; // 在线状态 userId: string; // 用户id };组件中使用import { contactListDataType, friendsListType, friendsDataType } from "@/type/ComponentDataType"; data(): contactListDataType<friendsListType<friendsDataType>> { return { friendsList: [ groupName: "我", totalPeople: 2, onlineUsers: 2, friendsData: [ username: "神奇的程序员", avatarSrc: "https://www.kaisir.cn/uploads/1ece3749801d4d45933ba8b31403c685touxiang.jpeg", signature: "今天的努力只为未来", onlineStatus: true, userId: "c04618bab36146e3a9d3b411e7f9eb8f" username: "admin", avatarSrc: "https://www.kaisir.cn/uploads/40ba319f75964c25a7370e3909d347c5admin.jpg", signature: "", onlineStatus: true, userId: "32ee06c8380e479b9cd4097e170a6193" groupName: "我的朋友", totalPeople: 0, onlineUsers: 0, friendsData: [] groupName: "我的家人", totalPeople: 0, onlineUsers: 0, friendsData: [] groupName: "我的同事", totalPeople: 0, onlineUsers: 0, friendsData: [] }深刻的理解到了typescript泛型的使用,经验++😄tag属性被移除我们在使用router-link时,它默认会渲染成a标签,如果想让他渲染成其它自定义标签,可以通过tag属性来修改,如下所示:<router-link :to="{ name: 'list' }" tag="div">然而,在vue-router的新版本中,官方将event和tag属性移除了,因此我们就不能这么使用了,当然官方文档中也给了解决方案使用v-solt来作为替代方案,上述代码中我们希望将其渲染成div,用v-solt的写法如下所示:<router-link :to="{ name: 'list' }" custom v-slot="{ navigate }"> <div @click="navigate" @keypress.enter="navigate" role="link" </div> </router-link>有关这一块的更多讲解,请移步官方文档:removal-of-event-and-tag-props-in-router-link组件无法外链文件当我把页面当组件进行引入声明时,发现vue3不支持将逻辑代码外链,像下面这样,通过src外链。<script lang="ts" src="../assets/ts/message-display.ts"></script>在组件中引用。<template> <message-display message-status="0" list-id="1892144211" /> </template> <script> import messageDisplay from "@/components/message-display.vue"; export default defineComponent({ name: "msg-list", components: { messageDisplay </script>然后,他就报错了,类型无法推断。 image-20201018224619607 尝试了很多方法,最后发现是不能通过src外链的问题,于是我把ts文件中的代码写在vue模版中报错就没了。必须使用as进行断言当我把代码搬到vue模版中后,它报了一些很奇怪的错误,如下所示imgContent变量可能存在多个类型,ts无法推断出具体类型,此时就需要我们自己进行断言给他指定类型,我用了尖括号的写法,他报错了,webstorm可能对vue3的适配不是很好,他的报错很奇怪,如下所示 image-20201018225114933 一开始,我看到这个错误我是一脸懵逼的,一个朋友告诉我用排除法,注释下距离它最近的代码,看看是否会报错,于是找到了问题根源,就是上面的类型断言的锅,将它修改后,问题解决。 image-20201018225618020问题是解决了,但是我很是想不通为何一定要用as,尖括号跟他是同等的才对,于是我翻了官方文档。 image-20201018225919664正如官方文档所说,启用jsx后就只能使用as语法了。可能vue3的模版语法默认是启用jsx的吧。ref数组不会自动创建数组在vue2中,在v-for里使用ref属性时会用ref数组填充相应的$refs属性,如下所示为好友列表的部分代码,它通过循环friendsList,将groupArrow和buddyList放进ref数组中。<template> <div class="group-panel"> <div class="title-panel"> <p class="title">好友</p> </div> <div class="row-panel" v-for="(item,index) in friendsList" :key="index"> <div class="main-content" @click="groupingStatus(index)"> <div class="icon-panel"> <img ref="groupArrow" src="../assets/img/list/tchat_his_arrow_right@2x.png" alt="左箭头"/> </div> <div class="name-panel"> <p>{{item.groupName}}</p> </div> <div class="quantity-panel"> <p>{{item.onlineUsers}}/{{item.totalPeople}}</p> </div> </div> <!--好友列表--> <div class="buddy-panel" ref="buddyList" style=""> <div class="item-panel" v-for="(list,index) in item.friendsData" :key="index" tabindex="0"> <div class="main-panel" @click="getBuddyInfo(list.userId)"> <div class="head-img-panel"> <img :src="list.avatarSrc" alt="用户头像"> </div> <div class="nickname-panel"> <!--昵称--> <div class="name-panel"> {{list.username}} </div> <!--签名--> <div class="signature-panel"> [{{list.onlineStatus?"在线":"离线"}}]{{list.signature}} </div> </div> </div> </div> </div> </div> </div> </template>我们通过$refs可以访问到相应的节点,如下所示。import lodash from 'lodash'; export default { name: "contact-list", methods:{ // 分组状态切换 groupingStatus:function (index) { if(lodash.isEmpty(this.$route.params.userId)===false){ this.$router.push({name: "list"}).then(); // 获取transform的值 let transformVal = this.$refs.groupArrow[index].style.transform; if(lodash.isEmpty(transformVal)===false){ // 截取rotate的值 transformVal = transformVal.substring(7,9); // 判断是否展开 if (parseInt(transformVal)===90){ this.$refs.groupArrow[index].style.transform = "rotate(0deg)"; this.$refs.buddyList[index].style.display = "none"; }else{ this.$refs.groupArrow[index].style.transform = "rotate(90deg)"; this.$refs.buddyList[index].style.display = "block"; }else{ // 第一次点击添加transform属性,旋转90度 this.$refs.groupArrow[index].style.transform = "rotate(90deg)"; this.$refs.buddyList[index].style.display = "block"; // 获取列表好友信息 getBuddyInfo:function (userId) { // 判断当前路由params与当前点击项的userId是否相等 if(!lodash.isEqual(this.$route.params.userId,userId)){ this.$router.push({name: "dataPanel", params: {userId: userId}}).then(); }上述写法在vue2没问题,但是在vue3中你得到的结果是报错,官方认为这种行为会变得不明确且效率低下,采用了新的语法来解决这个问题,通过ref来绑定一个函数去处理,如下所示。<template> <!---其它代码省略---> <img :ref="setGroupArrow" src="../assets/img/list/tchat_his_arrow_right@2x.png" alt="左箭头" /> <!---其它代码省略---> <div class="buddy-panel" :ref="setGroupList" style=""> </div> </template> <script lang="ts"> import _ from "lodash"; import { defineComponent } from "vue"; import { contactListDataType, friendsListType, friendsDataType } from "@/type/ComponentDataType"; export default defineComponent({ name: "contact-list", data(): contactListDataType<friendsListType<friendsDataType>> { return { groupArrow: [], groupList: [] // 设置分组箭头Dom setGroupArrow: function(el: Element) { this.groupArrow.push(el); // 设置分组列表dom setGroupList: function(el: Element) { this.groupList.push(el); // 列表状态切换 groupingStatus: function(index: number) { if (!_.isEmpty(this.$route.params.userId)) { this.$router.push({ name: "list" }).then(); // 获取transform的值 let transformVal = this.groupArrow[index].style.transform; if (!_.isEmpty(transformVal)) { // 截取rotate的值 transformVal = transformVal.substring(7, 9); // 判断分组列表是否展开 if (parseInt(transformVal) === 90) { this.groupArrow[index].style.transform = "rotate(0deg)"; this.groupList[index].style.display = "none"; } else { this.groupArrow[index].style.transform = "rotate(90deg)"; this.groupList[index].style.display = "block"; } else { // 第一次点击添加transform属性,旋转90度 this.groupArrow[index].style.transform = "rotate(90deg)"; this.groupList[index].style.display = "block"; )}完整代码请移步:contact-list.vueref更多描述请移步官方文档: v-for 中的 Ref 数组项目地址至此,项目已经可以正常启动了,重构工作也结束了,接下来要解决的问题就是vue-native-websocket这个插件无法在vue3中工作的问题了。一开始我以为把它在原型行挂载的写法改动下就可以了,然而是我想的太简单了,改动后编辑器是不报错了,但是在运行时会报很多错。无奈只好先把与服务端交互这部分代码移除掉了。接下来我会尝试重构vue-native-websocket这个插件,让其支持vue3。最后放上本文重构好的项目代码地址:chat-system写在最后公众号无法外链,文中链接可点击下方阅读原文进行查看。
不管前方的路有多苦,只要走的方向正确,不管多么崎岖不平,都比站在原地更接近幸福。前言文章中的配置,项目基于Vue CLI 3.0搭建,版本大于等于3.0不会有问题,其他环境版本请绕道😂。关闭线上源码在Vue.config.js中添加如下代码module.exports = { // 关闭线上源码 productionSourceMap: false, }移除后的效果移除console使用TerserPlugin插件实现安装插件:终端执行yarn add terser-webpack-plugin在vue.config.js中添加如下代码module.exports = { configureWebpack:{ optimization:{ minimizer: [new TerserPlugin({ terserOptions: { compress: { drop_console: true } } })] }插件的更多使用方法:terser-webpack-plugin使用cli自带的配置实现感谢评论区掘友(@小小茂茂)给的建议,代码已测试,可移除console在vue.config.js中添加如下代码// 关闭生产环境console configureWebpack(config) { if (process.env.NODE_ENV === 'production') { config.optimization.minimizer[0].options.terserOptions.compress.drop_console = true }写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊由于公众号字数限制,所以此处拿点鸡汤过来。上海虹桥机场有这么一个故事,一对恋人在机场分手,女对男说“你别等我了我们不会有结果,就像机场永远等不来火车,我们以后也不会有交集”没过几年 虹桥机场跟火车站连在了一起,设计这个工程的总工程师就是那个男的 ,只要有爱就有办法,任何人都可以无止境的对一个人好,但是前提提是值得。你对我一直很好,我也想对你很好,可是我们一直在错过,一开始你有一个异地恋女朋友,后面你们分手了,我又有一段恋情,后来我分手了,但是我们就毕业了,你没有对我告过白,只对我在毕业聚餐那天晚上唱了一首灰姑娘,你说那是对我唱的,视频我保存在了我心里。
谁经历的苦难多,谁懂得的东西也就多。前言在做头像上传功能时,为了防止用户多次点击,通常会在上传时添加一个遮罩,提示用户:图片正在上传中,上传完毕后,关闭这个遮罩层,本来想找个UI框架引入进来,使用框架提供的弹层,找了很多没找到满意的,干脆自己做一个吧😂。接下来就跟大家分享下如何制作一个插件,先跟大家展示下最终实现的效果:实现思路涉及到的知识点:Vue 构造器、实例挂载编写加载层业务代码,实现全局加载层的相关效果在插件包的index.js中进行相关封装定义插件对象,实现install方法使用Vue.extend构造器,将加载层业务代码作为构造器的参数创建子类实例化创建的构造器,挂载到HTMLElement实例上将构造器中的dom元素插入到body中添加实例方法,挂载至Vue原型实现显示和隐藏方法插件开发完毕实现过程搭建插件开发环境如图所示:在一个Vue项目的src目录下创建lib文件夹,用于存放各种插件在lib文件夹下创建我们的插件文件夹(FullScreenLoading)在插件文件夹下分别创建lib文件夹和index.js文件插件文件夹下的lib文件夹用于存放插件需要用到的资源文件index.js文件用于实现这个插件的所有逻辑插件业务代码(FullScreenLoading.vue)<template> <div id="loadingPanel" v-if="show"> <div class="container-panel"> <div class="arc"></div> <h1><span>{{tips}}</span></h1> </div> </div> </template> <script> export default { name: "FullScreenLoading", data(){ return{ tips:"加载中", show:false </script> <style src="./css/FullScreenLoading.css"> </style>插件样式代码(FullScreenLoading.css)body { font-family: 'Inconsolata', monospace; overflow: hidden; /*全屏遮罩层*/ #loadingPanel{ width: 100%; height: 100%; background: rgba(11,11,20,.6); position: fixed; top: 0; left: 0; z-index: 9999; display: flex; justify-content: center; align-items: center; #loadingPanel.container-panel{ width: 200px; height: 200px; display: flex; justify-content: center; align-items: center; #loadingPanel.container-panel.arc { width: 100px; height: 100px; border-radius: 50%; border-top: 2px solid #ffea29; border-left: 1px solid transparent; border-right: 1px solid transparent; animation: ring 2s infinite linear; #loadingPanel.container-panel.arc::before { position: absolute; margin: auto; top: 0; right: 0; bottom: 0; left: 0; width: 70px; height: 70px; border-radius: 50%; border-top: 2px solid #8d29ff; border-left: 1px solid transparent; border-right: 1px solid transparent; animation: ring 4s infinite linear reverse; content: ""; #loadingPanel.container-panel.arc::after { position: absolute; margin: auto; top: 0; right: 0; bottom: 0; left: 0; width: 0; height: 0; border-radius: 50%; border-top: initial; border-left: initial; border-right: initial; animation: solidCircle 1s infinite; content: ""; background: snow; #loadingPanel.container-panelh1 { position: absolute; height: 40px; margin: auto; top: 200px; left: 0; right: 0; bottom: 0; text-transform: uppercase; text-align: center; letter-spacing: 0.1em; font-size: 14px; font-weight: bold; color: white; /*动画定义*/ @keyframes ring { 100% { transform: rotate(360deg); @keyframes solidCircle { width: 0; height: 0; 75% { width: 40px; height: 40px; 100% { width: 0; height: 0; }插件逻辑文件(index.js)// 引入对应的组件 import loading from"./lib/FullScreenLoading"; // 定义对象:开发插件对象 const LoadPlugin = { // 插件包含install方法 install(Vue,options){ // 使用Vue.extend构造器,创建一个子类,参数为引入的FullScreenLoading组件 const loadingSubclass = Vue.extend(loading); // 实例化loadingSubclass,挂载到HTMLElement实例上 const Profile = new loadingSubclass({ el: document.createElement('div') // 插入到body中,FullScreenLoading.vue中的template模板内容将会替换挂载的元素,Profile.el中到内容最终为模版到内容 document.body.appendChild(Profile.$el); // 判断是否有传参数:替换组件内的默认显示数据 if(options){ if(options.tips){ Profile.tips = options.tips; // 添加实例方法,挂载至Vue原型 Vue.prototype.$fullScreenLoading = { // 定义显示隐藏的方法 show(tips) { Profile.show = true; if (tips) { // 替换组件的默认数据 Profile.tips = tips; hide() { Profile.show = false; // 导出对象 exportdefault LoadPlugin;至此,插件开发完毕。本文开头实现的效果,项目地址:chat-system插件发布在终端进入到FullScreenLoading文件夹内创建README.md编写插件描述以及使用方法终端执行npm init命令,生成package.json文件npm init # 应用包名,要先去https://www.npmjs.com/官网查一下是否与你的包重复 package name: (@likaia/vue-fullscreenloading) # 版本号 version: (1.0.0) # 包描述 description: 全屏加载层插件,提升用户体验,防止用户误操作。 # 入口文件 entry point: (index.js) # 测试命令,直接回车即可 testcommand: # 项目git仓库地址 git repository: https://github.com/likaia/chat-system.git # 关键词:用户在npm官网搜索包时所用的关键词 keywords: vue-loading FullScreenLoading author: likaia # 开源协议,直接回车即可 license: (ISC)发布到npm仓库# 登录,没有账号的需要先去官网注册:https://www.npmjs.com/ npm login # 发布至npm npm publish --access=public登录成功发布成功在npm官网搜索刚才发布的包包地址:vue-fullscreenloading使用插件终端执行: yarn add @likaia/vue-fullscreenloading在main.js中进行引用import FullScreenLoading from'@likaia/vue-fullscreenloading' Vue.use(FullScreenLoading);在业务中使用uploadAvatar:function (e) { console.log("上传点击了"); // 显示全局加载层 this.$fullScreenLoading.show("上传中"); let file = e.target.files[0]; // 构造form对象 let formData = new FormData(); // 后台取值字段 | blob文件数据 | 文件名称 formData.append("file",file,file.name); // 调用上传api this.$api.fileManageAPI.baseFileUpload(formData).then((res)=>{ console.log(res); const fileName = `${base.lkBaseURL}/uploads/${res.fileName}`; // 更改默认头像状态 this.isDefaultAvatar = false; // 头像赋值 this.avatarSrc = fileName; // 隐藏全局加载层 this.$fullScreenLoading.hide(); console.log(e); }写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言在写公司项目时,遇到了集成layim实现在线客服的一个需求,经过我的一番折腾后,终于将layui集成了进来,接下来就跟大家分享下我的解决方案,欢迎各位感兴趣的开发者阅读本文😁获取layimlayui官方提供了npm的安装方法,我司使用的是layui正版授权的layim,今天在折腾时发现,从npm仓库获取layui,里面自带了layim,大家可以去白嫖一波,不过大家需要注意版权问题(如下图所示,layui官网进行了声明)。商用项目的话还是建议大家获取正版授权,毕竟作者也不容易。在项目中安装layui-src依赖yarn add layui-src | npm install layui-src安装成功在node_modules下找到layui-src下的build文件夹复制到项目的public目录下为了项目结构清晰,我们将build文件夹重命名为layim集成并使用layim在vue项目中集成并使用layui我内心是拒绝的,因为vue和layui完全是两个东西,两个框架底层设计理念完全不同,奈何公司需要layim这个东西,layim又依赖于layui,毕竟公司安排的最大嘛,我就只能从了公司🤪下述操作适用于vue cli3.0搭建的项目,cli版本高于3.0是没有任何问题,小于3.0就会有问题了,望大家悉知。打开项目的index.html文件(public/index.html),在head中添加如下代码<!--引入layim--> <link rel="stylesheet" href="<%= BASE_URL %>/layim/css/layui.css"> <script type="text/javascript" src="<%= BASE_URL %>/layim/layui.js"></script>关闭eslint对layui的校验如果你的项目没有配置eslint,那么这一步可以省略,如果配置了请按照我的下述操作进行配置,否则项目会报错无法启动。打开项目根目录的.eslintrc.js文件,在module.exports添加如下代码globals: { layui: true },在组件中测试是否成功console.log("layui集成成功"); console.log(layui); layui.use("layim", layim => { console.log("layim集成成功"); console.log(layim); });启动项目,查看控制台打印结果我们发现layui和layim对象都已经有了数据,接下来就可以根据layui官方提供的文档进行项目开发了。由于layui是直接dom操作的,依赖于jquery,vue是数据驱动dom,所以如果不是很必要的话,不建议在vue中即成layui,因为后续使用过程中会有很多兼容性问题发生,有很多坑要填的。写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言队列作为一种数据结构,在现实生活中它可应用于电影院、自助餐厅等场合,排在第一个的人会先接受服务。在计算机应用领域里,多个文档的打印就是一个队列,排在第一的文档会先执行打印操作。本文将用TypeScript实现队列与双端队列这两种数据结构,并用其解决计算机科学领域中的两道经典题,欢迎各位感兴趣的开发者阅读本文。队列的实现本文主要讲解队列用代码的实现,如果对队列这种数据结构不了解的开发者可以移步我的另一篇文章:栈与队列实现思路队列遵循先进先出(FIFO)原则,根据队列的特性,我们可以知道要实现一个队列必须具备以下方法:入队,将一个新元素加入队列中(往对象中添加一个key)出队,将队首的元素取出(根据key来获取),并返回队首的元素。判断队列是否为空,判断队列中的元素数量是否为0(队列大小 - 队首元素位置)队首元素,获取当前队列的队首元素并返回。队列大小,获取队列中的元素数量并返回(队列大小 - 队首元素位置)。清空队列,删除队列中的所有元素。(初始化队列的内部变量)。队列内所有元素,将队列中的元素用逗号拼接成字符串并返回(遍历队列中的元素)。实现代码有了思路,我们就可以编码了。接下来,我们将上述实现思路转换为代码:新建一个Queue.ts文件使用接口声明队列内部对象类型interface QueueObj { [propName: number] : any; }在构造器中声明并初始化队列需要的三个变量:队列大小、队首元素、队列对象private count: number; private lowestCount: number; private items: QueueObj; constructor() { this.count = 0; this.lowestCount = 0; this.items = {}; }实现入队函数(enqueue),参数为任意类型的数据,将队列的大小作为队列对象的key。enqueue(item: any) { // 队列的末尾添加元素: 将队列的大小作为key this.items[this.count] = item; this.count++; }实现出队函数(dequeue),存储队首元素,移除队列对象中的队首元素key,队首元素位置自增。dequeue() { if(this.isEmpty()){ returnundefined; const result = this.items[this.lowestCount]; // 删除队首元素 deletethis.items[this.lowestCount]; // 队首元素自增 this.lowestCount++; return result; }判断队列是否为空(isEmpty),判断当前队列大小 - 当前队首元素位置是否等于0isEmpty() { returnthis.count - this.lowestCount === 0; }获取队首元素(peek),以当前队首元素位置为key获取队列队对象中的值并返回。peek() { if(this.isEmpty()){ returnundefined; returnthis.items[this.lowestCount]; }获取队列大小(size),当前队列大小 - 队首元素位置size() { returnthis.count - this.lowestCount; }清空队列(clear),初始化构造器中的三个变量。clear() { this.count = 0; this.lowestCount = 0; this.items = {}; }获取队列中的所有元素,遍历对象中的元素,用逗号拼接成字符串并返回。toString() { if(this.isEmpty()){ return""; let objString = `${this.items[this.lowestCount]}`; for (let i = this.lowestCount + 1; i < this.count; i++){ objString = `${objString},${this.items[i]}`; return objString; }完整代码请移步:Queue.ts编写测试代码队列实现后,接下来我们来测试下队列中的方法是否能正常运行。// 队列执行测试 import Queue from"./lib/Queue.ts"; const queue = new Queue(); // 入队 queue.enqueue("队列中的第一条数据"); queue.enqueue("队列中的第二条数据"); queue.enqueue("队列中的第三条数据"); // 出队 queue.dequeue(); // 获取队首元素 console.log(queue.peek()); // 获取队列大小 console.log(queue.size()); // 获取队列中的所有元素 console.log(queue.toString()); // 判断队列中是否有数据 console.log(queue.isEmpty()); // 清空队列 queue.clear();执行结果如下:双端队列双端队列是一种允许我们同时从前端和后端添加和移除元素的特殊队列。双端队列同时遵守了先进先出和后进先出的原则,所以可以说它是一种把队列和栈相结合的一种数据结构。现实中用到双端队列的例子有很多,例如电影院、餐厅排队的队伍。排队买电影票的人当他买到电影票后,离开了队伍,此时他想咨询一些其他小问题时,他可以直接去队伍的最前面咨询问题。排在队伍后面的人,临时有其他事情无法买票,他就会从队伍的末尾离开。在计算机科学中,存储一系列的撤销操作就用到了双端队列,每当用户在软件中进行了一个操作,该操作就会被存储在一个双端队列中,当用户点撤销操作时,该操作会从队列的末尾弹出,在进行了预先定义的一定数量的操作后,最下执行的操作就会从队首移除。实现思路双端队列相比队列多了两端都可以出入元素,因此普通队列中的获取队列大小、清空队列、队列判空、获取队列中的所有元素这些方法同样存在于双端队列中且实现代码与之相同。由于双端队列两端都可以出入元素,那么我们需要实现以下函数:队首添加元素,添加元素时需要判断队列是否为空,以及队首元素是否为0。队尾添加元素,等同于队列的入队操作。获取队首元素,等同于队列的获取队首元素获取队尾元素,等同于栈的获取栈顶操作。删除队首元素,等同于出队操作。删除队尾元素,等同与出战操作。观察上述我们要实现的函数后,我们发现双端队列除了队首添加元素与之前我们实现的差别很大,其他的函数我们之前都已经实现过了,所以此处仅讲解队首添加元素的实现。想了解其他函数的具体实现请移步我的另一篇文章:数组实现栈与对象实现栈的区别队首添加元素的实现思路如下:如果队列为空,直接调用队尾添加元素函数。如果队首元素key大于0,则需要将当前队首元素key-1,然后将当前元素放入队列中。如果队首元素key等于0,则需要将队列中的元素整体向后移动一位,空出0号key出来,队首元素重新赋值为0,然后将当前元素放入0号key中。实现代码接下来,我们将上述思路转换为代码。新建一个Deque.ts文件声明双端队列内部对象的类型interface DequeObj { [propName: number]: any; }在构造器中声明双端队列需要用到的变量并初始化private count: number; private lowestCount: number; private items: DequeObj; constructor() { this.count = 0; this.lowestCount = 0; this.items = {}; }实现队首添加元素函数addFront(item: any) { if (this.isEmpty()){ this.addBack(item); }elseif(this.lowestCount > 0){ // 队首元素大于0 this.lowestCount--; this.items[this.lowestCount] = item; }else{ // 队首元素为0,我们需要将将队列里的0号key空出来,其他数据整体向后移动一位。 for (let i = this.count; i > 0; i--){ this.items[i] = this.items[i - 1]; // 队列长度自增 this.count++; // 队首元素设为0 this.lowestCount = 0; // 为队首的0号key添加当前元素 this.items[0] = item; }完整代码请移步:Deque.ts编写测试代码// 双端队列测试 import Deque from"./lib/Deque.ts"; const deque = new Deque(); // 队列为空的情况下,往队首添加元素 deque.addFront("队首添加元素"); console.log(deque.peekFront()); // 队尾添加元素 deque.addBack("队尾添加元素"); console.log(deque.peekBack()); // 队首元素等于0的情况往队首添加元素 deque.addFront("队首元素等于0添加元素"); console.log(deque.peekFront()); // 队首元素大于0,往队首添加元素 deque.removeFront(); deque.addFront("队首元素大于0添加元素"); console.log(deque.peekFront()); // 获取队列大小 console.log("队列大小:",deque.size()) // 队列末尾添加元素 deque.addBack("队列末尾添加元素") // 获取队列中的所有元素 console.log("队列中的所有元素: ",deque.toString()) // 移除队首元素 deque.removeFront(); // 移除队尾元素 deque.removeBack(); // 获取队首元素 console.log("队首元素: ",deque.peekFront()); // 获取队尾元素 console.log("队尾元素: ",deque.peekBack());执行结果如下:解决问题上面我们实现了队列和双端队列,接下来我们就通过两个例子来理解这两种数据结构。队列实现击鼓传花游戏由于队列经常被应用在计算机领域和我们的现实生活中,就出现了一些队列的修改版本。例如循环队列,我们通过实现一个击鼓传花游戏来理解循环队列。游戏规则击鼓传花游戏的规则如下:一群人围成一个圆圈,放一朵花在任意一个人手里。开始打鼓,拿到花的人会把花尽快的传给旁边的人。鼓声停止,这个时候花在谁手里,谁就退出圆圈,结束游戏。重复这个过程,直至只剩下一个人,这个人就是游戏的获胜者。实现思路知道游戏规则后,我们来捋一下实现思路:声明一个函数,参数为:参与人员数组,多少次为一轮函数内部实例化一个队列,声明淘汰人员列表变量。将参与人员入队(参与人员围成一个圆圈)模拟击鼓传花,以传进来的次数为条件遍历队列,将队列的队顶元素追加至队尾(如果你将花传给了旁边的人,你被淘汰的威胁就立刻解除了)。传进来的次数遍历完成(鼓声停止),队首元素出栈,将队首元素追加至淘汰人员列表中。队列中只剩下一个元素时,剩余元素出队,返回胜利者和淘汰者列表。实现代码实现思路捋清后,我们将上述思路转换为代码:import Queue from"./lib/Queue.ts"; * 击鼓传花函数 * @param nameList 参与人员列表 * @param num 多少次为一轮 * @returns {{eliminates: [], winner: string}} const hotPotato = (nameList=[], num=0)=> { // 实例化一个队列 const queue = new Queue(); // 淘汰人员列表 const eliminateList = []; // 所有参与人员入队 for (let i = 0; i < nameList.length; i++){ queue.enqueue(nameList[i]); // 队列的大小大于1就继续执行 while (queue.size() > 1){ // 模拟击鼓传花,给定一个数字,然后遍历队列,从队列开头移除一项,再将其添加到队列末尾。 for (let i = 0; i < num; i++){ // 将队首元素添加至队尾(如果你将花传给了旁边的人,你被淘汰的威胁就立刻解除了) queue.enqueue(queue.dequeue()); // 鼓声停止,传花结束,将当前手上有花的人(队首元素)淘汰。 eliminateList.push(queue.dequeue()); // 游戏结束,返回淘汰者和胜利者 return { eliminates: eliminateList, winner: queue.dequeue() }编写测试代码上面我们实现了击鼓传花游戏的函数,接下来我们来测下我们编写的函数是否正确。const names = ["张三","李四","王五","朱六","郝七","刘八","彭九"]; // 每隔9次淘汰一轮 const result = hotPotato(names,9); for (let i = 0; i < result.eliminates.length; i++){ const name = result.eliminates[i]; console.log(`${name},在击鼓传花游戏中被淘汰`); console.log(`游戏胜利者: ${result.winner}`);完整代码请移步:Examples.js执行结果如下:双端队列实现回文检查器回文是正反都能读通的单词、词组、数或一系列字符的序列,例如madam、racecar。实现回文检测有多种方式,最简单的方式为:将字符串反向排列并检查他与原字符是否相同。如果两者相同那么它就是一个回文。我们也可以用栈来解决这个问题,但是如果用数据结构来解决回文问题的话,使用双端队列是最简单的。实现思路回文的规则是正反都能读通,那么我们可以将字符串首字母和末尾字母一一进行比较,如果都相等的话那么这个字符串就是回文。声明一个函数,参数为:要进行检测的字符串去除字符串的空格并将其全转为小写字母遍历字符串,将字符串的每个字符加入双端队列中。遍历队列,队首出队和队尾出队判断队首和队尾的字符是否相等,如果不想等则回文结果为false如果队列的大小大于1且会问结果为true则继续比对队首元素和队尾元素实现代码我们捋清了回文的实现思路后,就可以将上述思路转换为代码了:import Deque from"./lib/Deque.ts"; * 回文函数 * @param sourceString 需要进行判断是否为回文的参数 * @returns {boolean} const palindromeDetection = function (sourceString) { // 判断参数是否有效 if(sourceString === undefined || sourceString === null || sourceString.length === 0){ returnfalse; // 实例化一个双端队列 const deque = new Deque(); // 去除字符串的空格并将其转为小写字母 const lowerString = sourceString.toLocaleLowerCase().split(" ").join(""); // 回文校验结果 let isEqual = true; let firstChar,lastChar; // 字符串入队 for (let i = 0; i < lowerString.length; i++){ // charAt获取指定索引处的字符 deque.addBack(lowerString.charAt(i)); // 队列大小大于1且回文校验结果为true则继续执行校验 while (deque.size() > 1 && isEqual){ // 队首和队尾元素出队 firstChar = deque.removeFront(); lastChar = deque.removeBack(); // 比较队尾元素和队首元素是否相等 if(firstChar !== lastChar){ isEqual = false; // 返回验证结果 return isEqual; }编写测试代码上述代码实现了回文函数,接下来我们来试下上述代码是否能正常运行const testString = "madam"; const testStr = "admin"; const results = palindromeDetection(testString); const testStrResult = palindromeDetection(testStr); if (results){ console.log(`${testString}是回文`) }else{ console.log(`${testString}不是回文`); if(testStrResult){ console.log(`${testStr}是回文`); }else{ console.log(`${testStr}不是回文`);完整代码请移步:Examples.js执行结果如下写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言5栈作为一种数据结构,它可以应用在很多地方,当你需要经常获取刚存放进去的数据时,那么栈这种数据结构将是你的首选。栈的实现方式一般有两种:数组实现和对象实现,这两种实现方式最终实现的功能都是一样的,但是在性能上却有着很大的差别。本文将详细讲解这两种实现方式的差异并用TypeScript将其实现,欢迎各位感兴趣的开发者阅读本文。数组实现栈本文讲解的是栈用代码的实现,如果对栈这种数据结构还不是很了解的话,可以移步我的另一篇文章:栈与队列实现思路栈的核心思想为后进先出(LIFO),那么我们可以用数组来描述栈。接下来,我们来看下,一个栈都需要具备哪些功能:入栈,添加一个新元素至栈顶(数组的末尾)。出栈,将栈顶的元素移除并返回被移除的元素。获取栈顶元素,获取当前栈顶元素返回。判断栈是否为空,判断栈(数组)内是否有数据。清空栈,移除栈内所有的元素。获取栈大小,返回栈里的元素个数。输出栈内数据,将栈中的所有元素以字符串的形式返回。我们分析完栈都需要具备哪些功能后,发现数组中提供了很多现成的API可以实现上述功能,接下来,跟大家分享下上述功能的实现思路。入栈(push),可以使用数组的push方法直接往数组的末尾添加元素。出栈(pop),可以使用数组的pop方法直接移除栈中的元素,该方法会返回当前被移除的元素。栈顶元素(peek),可以通过数组的长度-1获取到数组中的最后一个元素。栈是否为空(isEmpty),可以通过判断数组的长度是否为0来实现。清空栈(clear),可以将数组直接赋值为空或者调用出栈方法直至栈中的数据为空。栈大小(size),可以返回数组的长度。输出栈内数据,可以调用数组的toString方法将数组转换为字符串。实现代码有了实现思路后,我们就可以将上述实现思路转换为代码了。新建一个Stack.ts文件定义栈并规定其类型private items: any[];在构造器中初始化栈constructor() { this.items = []; }根据实现思路实现栈中的函数// 入栈 push(item:any) { this.items.push(item); // 出栈 pop() { returnthis.items.pop(); // 返回栈顶元素 peek() { returnthis.items[this.items.length - 1]; // 判断栈是否为空 isEmpty() { returnthis.items.length === 0; // 清空栈栈内元素 clear() { this.items = []; // 获取栈内元素数量 size():number{ returnthis.items.length; // 将栈内元素转为字符串 toString(){ returnthis.items.toString(); }完整代码请移步:Stack.ts编写测试代码上述代码我们实现了一个栈,接下来我们往栈中添加几条数据,测试栈内的方法是否正确执行。新建一个StackTest.js文件实例化一个栈const stack = new Stack();测试栈内方法是否正确执行// 入栈 stack.push("第一条数据"); stack.push("第二条数据"); // 出栈 stack.pop(); // 返回栈顶元素 console.log(stack.peek()); // 查看栈大小 console.log(stack.size()); // 判断栈是否为空 console.log(stack.isEmpty()); // 返回栈内所有元素 console.log(stack.toString()) // 清空栈 stack.clear()完整代码请移步:StackTest.js执行结果如下对象实现栈实现一个栈最简单的方式是通过数组存储每一个元素。在处理大量数据时,我们需要评估如何操作数据是最高效的。在使用数组时,大部分方法的时间复杂度都为O(n),我们需要迭代整个数组直至找到目标元素,在最坏的情况下我们需要迭代数组的每一个位置。数组是元素的一个有序集合,为了保证元素排列有序,它会占用更多的内存空间。如果我们可以直接获取元素,占用更少的内存空间,并且仍然保证所有元素都按照我们的需要进行排列,就属于最优解决方案了。实现代码我们可以使用一个对象来存储所有的栈元素,保证它们的顺序并且遵循LIFO原则。接下来我们来看看如何使用对象来实现栈。新建一个ObjStack.ts文件定义栈对象结构interface StackObj { [propName: number] : any; }定义栈并规定其类型,count用于记录栈的大小。private items: StackObj; private count: number;在构造器中初始化栈相关变量this.items = {}; this.count = 0;入栈,当前栈的大小为新元素的key。push(item: any) { this.items[this.count] = item; this.count++; }出栈,当前栈大小-1,取出栈顶元素,删除栈顶元素,返回取出的栈顶元素pop() { if(this.isEmpty()){ returnundefined; this.count--; const result = this.items[this.count]; deletethis.items[this.count]; console.log(this.items); return result; }返回栈顶元素,以当前栈大小-1为key获取其对应的value值。peek() { if(this.isEmpty()){ returnundefined; returnthis.items[this.count - 1]; }判断栈是否为空,清空栈内元素,获取栈内元素数量// 判断栈是否为空 isEmpty() { returnthis.count === 0; // 清空栈内元素 clear() { this.items = []; this.count = 0; // 获取栈内元素数量 size():number{ returnthis.count; }将栈内元素转为字符串,遍历当前栈对象中的数据,将栈中的数据用逗号拼接并返回。toString(){ if (this.isEmpty()){ return""; let objString = `${this.items[0]}`; for (let i = 1; i < this.count; i++){ objString = `${objString},${this.items[i]}` return objString; }完整代码请移步:ObjStack.ts编写测试代码上述代码我们用对象实现了一个栈,接下来我们往栈中添加几条数据,测试栈内的方法是否正确执行。新建一个StackObjTest.js文件实例化一个栈const stack = new ObjStack();测试栈内方法是否正确执行// 入栈 stack.push("第一条数据"); stack.push("第二条数据"); // 出栈 stack.pop(); // 返回栈顶元素 console.log(stack.peek()); // 查看栈大小 console.log(stack.size()); // 判断栈是否为空 console.log(stack.isEmpty()); // 返回栈内所有元素 console.log(stack.toString()) // 清空栈 stack.clear()完整代码请移步:StackObjTest.js执行结果如下二者的区别数组大部分方法的时间复杂度都为O(n),数组中的元素是一个有序集合,为了保证元素排列有序,它会占用更多的内存空间。对象可以通过key直接访问到目标元素时间复杂度为O(1),我们可以直接目标元素进行操作,速度明显比数组快了很多倍。接下来,我们通过一个实例来看看这两者在执行速度上的差异。十进制转二进制把十进制转为二进制,需要将该十进制除以2并对商取整,直到结果是0为止。声明一个函数参数为一个十进制数const decimalToBinaryStack = function (decNumber) { }函数内部声明一个变量,用于接收当前传进来的参数进行除法运算后得到的值。// 传进来的十进制数 letnumber = decNumber;函数内部实例化一个栈,用于保存模运算后得出的值。函数内部声明两个变量,用户保存当前模运算的值和最终生成的二进制字符串// 余数 let rem; // 二进制结果 let binaryString = "";while循环,判断当前参数进行除法运算后得到的值是否为0,如果不为0就对当前结果进行模运算,将模运算得到的值入栈,对当前结果进行除法运算,直至当前结果为0。while (number > 0) { // 模运算 rem = Math.floor(number % 2); // 将余数入栈 stack.push(rem); // 当前十进制结果除以二 number = Math.floor(number / 2); }while循环,将栈中的数据取出拼接到二进制结果字符串中去while (!stack.isEmpty()) { binaryString += stack.pop().toString(); }返回二进制结果字符串return binaryString;完整代码请移步:Examples.js实现代码如上所述,唯一不同的就是一个使用的是对象栈一个使用的数组栈,接下来我们来看下不同栈的运行时间差距。写在最后公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊
前言写Vue项目时,使用CLI搭建项目,勾选上ESLint+Prettier就会自动帮我们配置好,最近写的代码脱离了webpack,想规范自己的代码格式,搜了很多文章,大都是基于webpack的。经过我一番折腾后,终于搞出了不需要webpack就能让编辑器结合ESLint对代码进行格式校验,接下来就跟大家分享下我的实现过程,欢迎各位感兴趣的开发者阅读本文。环境搭建本文使用的编辑器器是WebStorm,采用的包管理工具是yarn。安装ESLint开始之前,先跟大家看下我的项目结构,是一个很简单的js项目。初始化一个项目# 项目根目录执行,执行后填写相关信息,初始化成功后,项目根目录会多一个package.json文件 yarn init安装依赖# 项目根目录执行,执行完成后项目根目录会多一个yarn.lock文件 yarn install安装ESLint# 项目根目录执行 yarn add eslint --dev初始化ESLint# 项目根目录执行 yarn eslint --init # 执行后,会出现如下选择 # 你想如何使用ESLint,我选择第二项校验代码和解决方案 ✔ How would you like to use ESLint? · problems # 使用什么作为项目模块,我选择import/export ✔ What type of modules does your project use? · esm # 项目使用哪个框架,我选择第三项不使用框架 ✔ Which framework does your project use? · none # 项目是否使用typescript,我选择yes ✔ Does your project use TypeScript? · No / Yes # 代码运行环境,我选择了浏览器和node ✔ Where does your code run? · browser, node # eslint配置文件的格式,我选择json配置格式 ✔ What format do you want your config file to be in? · JSON # 是否安装如下依赖 The config that you've selected requires the following dependencies: @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest # 这里选择no,一会自己安装缺少的依赖 ✔ Would you like to install them now with npm? · No / Yes Successfully created .eslintrc.json file in /Users/likai/Documents/WebProject/JavaScript-test ✨ Done in 85.77s.安装插件让ESLint支持TypeScriptyarn add typescript @typescript-eslint/parser @typescript-eslint/eslint-plugin --dev执行完上述步骤后,项目目录如下图所示安装prettier安装插件yarn add prettier --dev配置prettier规则,项目根目录创建.prettierrc.json文件,添加下述代码{ "printWidth": 160, // 每一行的代码字符 "tabWidth": 4, // tab的长度 "useTabs": true, // 使用tab "singleQuote": false, // 使用单引号代替双引号 "semi": true, // 末尾分号 "trailingComma": "none", // 删除数组末尾逗号 "bracketSpacing": true // 大括号之间的空格 }配置编辑器配置ESLint打开webstorm的设置面板,按照图中所示进行设置在eslint配置文件处右击,按照图中所示进行操作配置prettier打开webstorm的设置面板,按照图中所示进行设置更多配置本文只介绍ESLint和prettier的入门使用,更多配置请移步:ESLint文档: ESLintPrettier文档: Prettier结果测试随便打开一个ts文件,我们发现已经有eslint的相关提示了。测试下自动格式化代码,如图所示写完代码后按Ctrl+S即可自动格式化