周四剪发；周六上午染发购物买年货，周日中午聚餐，收拾屋子。周一收拾屋子。购物清单：[ ] 清洗： 强力去污剂；衣架；[ ] 肉类：牛肉可以多买点，可以买个羊腿，猪肝大肠要买就买一点最多买两盘菜的量；鱼尽量买少刺的或者无刺的；可以买点鸡胸肉，海鲜【少买少吃】；清蒸鲈鱼[ ] 坚果零食：再买点瓜子就行[ ] 水果：[ ] 饮料：够了[ ] 调料：醋，老抽，生抽，耗油，料酒，盐，白糖，食用油，大料，火锅底料，酸菜鱼料包，（炖肉料）[ ] 干货：干木耳，干香菇，黄花菜，粉条，海米干，虾皮；[ ] 蔬菜沙拉：【冰草】、黄瓜、樱桃萝卜、圣女果、苦菊、生菜，有时候还会放一些紫衣甘蓝和西兰花；青瓜，圣女果，生菜，水果胡萝卜，黄椒，青椒，苦菊，包菜，紫甘蓝，菜花，还可以加些鲜柠檬，橘子，火龙果更美味[ ] 火锅，微波炉等食材清单，搜索就可以五花肉；各种肉卷【牛肉，羊肉，肥牛，培根】；排骨；鸡胸肉；午餐肉、火腿肠；鸭肠；黄喉；毛肚；鸭血巴沙鱼；鱿鱼；虾；丸子类菌类（属于高嘌呤食物）：金针菇；茶树菇；杏鲍菇；香菇；海鲜菇；银耳；木耳；口蘑菇蔬菜类：藕；笋；土豆；胡萝卜；秋葵；海带；茼蒿；菠菜；生菜；小白菜；大白菜；油麦菜豆制品：豆面筋；豆泡；豆皮；腐竹；冻豆腐；鱼豆腐；宽粉；粉丝；年糕1、蔬菜类的火锅配菜香蒜、大葱、白菜、菠菜、莴笋、卷心菜、土豆、莲藕、茭白、冬笋、春笋、白萝卜、胡萝卜、冬瓜、菜花、金针菜、蘑菇、香菇、平菇 、丝瓜、鲜豌豆、四季豆、金针菇、黄豆芽、绿豆芽、豆腐、豆腐干、香菜、空心菜、生菜、茼蒿、油麦菜、上海青以及荸荠等。2、肉类的火锅配菜(1)畜肉：羊肉、牛肉、猪肉、腊肉、香肠、猪灌肠、猪牛羊的肾、 肝心、肝蹄筋、肝心、肝蹄筋、牛鞭、猪肠、猪脑花以及猪脊髓等。(2)禽肉：鸡肉、鸭肉、鹅肉、鸡翅、鸡血、鸭血、鸡鸭额肝、肠、肫、肝、心、胗、爪等。(3)水产：鲫鱼、草鱼、鲤鱼、鳝鱼、泥鳅、河蟹、河虾、海蟹、海虾、鲜贝、带鱼等鱼虾类，水发海参、水发墨鱼、水发鱿鱼、水发鱼肚、鱼唇、鱼翅、鲜贝、带鱼以及水发海带等。(4)丸子：糖醋丸子、虾仁丸子、鱼香丸子、鸡肉丸子、猪肉丸子等。(5)野味类(人工饲养)：野鸡、野鸭、鹌鹑、狗肉、狗鞭、蛇肉、田鸡、蜗牛、牛蛙以及龟肉等。3、干货类的火锅配菜干金针菜、笋干、粉丝、米线、面条、饺子、红枣、魔芋、木耳、银耳、香菇、口蘑以及竹荪等。锅底辣过瘾选它：重庆牛油辣、青椒辣。不辣养生选它：野山菌汤、花胶鸡汤、猪肚鸡汤、番茄汤底。蘸料调料：海鲜汁、 XO酱、醋、 香油 、菌菇酱、腐乳、辣椒酱、芝麻酱。辅料：香菜、香葱、芝麻粒、花生碎、蒜泥、小米辣。附录1、维生素：三高加尿酸高是指高血压、高血脂、高血糖、尿酸高，饮食上可以多摄入维生素含量较丰富的食物，比如青椒、西红柿、猕猴桃、橙子、西兰花等，有利于补充机体所需要的微量元素。2、膳食纤维：三高加尿酸高的患者还要多摄入富含膳食纤维的食物，例如燕麦片、荞麦、红豆、绿豆、玉米、小米等。3、蛋白质：三高加尿酸高的患者饮食上还应摄入蛋白质含量较丰富的食物，例如牛奶、鱼肉、虾肉、瘦肉、豆制食品等。4、低嘌呤食物：常见的有冬瓜、茄子、青菜、白菜、苦瓜、萝卜、西葫芦、马铃薯等。
js的事件循环机制下，记录一个意外错误
js的事件循环机制是前端的基础概念。下面我们先看两段代码：setTimeout(()=>{ console.log("children2") Promise.resolve().then(()=>{ console.log("children3") }) },0) new Promise(function(resolve,reject){ console.log("children4") setTimeout(function(){ console.log("children5") resolve("children6") },0) }).then(res=>{ console.log("children7") setTimeout(function(){ console.log(res) },0) }) console.log("start") 上面的代码输出：children4、start、children2、children3、children5、children7、children6async function async1() { console.log("async1 start"); await async2(); console.log("async1 end"); } async function async2() { console.log("async2"); } console.log("script start"); setTimeout(function () { console.log("setTimeout"); }, 0); async1(); new Promise(function (resolve) { console.log("promise1"); resolve(); }).then(function () { console.log("promise2"); }); console.log("script end");上面的代码输出：script start、async1 start、async2、promise1、script end、async1 end、promise2、setTimeout第二道题时，我出现一个疑惑，当await 后面跟的不是一个 Promise 对象（而是一个同步值或已经解决的 Promise），那么await 后面的代码会被放入微任务队列吗?验证代码：async function example() { console.log('Start'); await Promise.resolve(); // 立即解决的 Promise console.log('After await'); } console.log('Before async call'); example(); console.log('After async call start'); // 输出顺序： // Before async call // After async call start // Start // After await上面的代码表明：如果 await 后面跟的不是一个异步操作，虽然await 不会真正“等待”任何东西，但它仍然会将后续代码放入微任务队列中。结论：无论 await 后面跟的是否是一个真正需要等待的异步操作，它都会导致后续代码被放入微任务队列中执行。
什么是 Monorepo？一个 Monorepo 是一个单一的存储库，包含 多个不同的项目 ，和 明确的关系 。和 Monorepo 对立的是传统的 Polyrepo 模式，每个项目对应一个单独的仓库来分散管理现代的前端工程越来越多的都会选择 Monorepo 的方式进行开发，比如 Vue、React 等知名开源项目都采用的 Monorepo 的方式进行管理。Monorepo 的组织方式如下├── packages | ├── pkg1 | | ├── package.json | ├── pkg2 | | ├── package.json ├── package.json在 packages 目录下存放多个子项目，当然也可以叫其他名字，每个子项目都有自己的 package.json 文件，用来管理子包。在更换新的方式之前，一定要知道原先的方式有什么缺点。接下来就先聊聊 Polyrepo 的方式在生产上都存在着哪些弊端。Polyrepo 的弊端1. 代码重复Polyrepo 容易导致代码重复，主要是因为不同的项目都有自己的独立代码仓库。在维护一些基础组件或者工具函数时，会想着直接从 A 仓库将代码复制到 B 仓库里使用，这样很方便，但是却给后续维护成本带来了很大的负担。一旦出现需要修改的地方，需要在 多个仓库中进行修改 ，这可能会导致修改过程出现错误，并可能导致代码的不一致。那么当然我们可以不进行复制，将公共逻辑抽离出来，单独发包，作用一个 npm 模块引入到项目中，需要修改时，也只用修改一份代码，然后重新发布即可。但是这整个流程还是太复杂了，并不是完美的解决方式，仅仅是为了修改一点点东西，就需要去发包、更新，这太麻烦了。小结：不同仓库之间的割裂感、导致复用代码的成本很高，开发体验差。或者需要在多个仓库中重复编写相同的代码，从而导致代码的不一致性和冗余性。2. 版本管理因为多个仓库分别管理自己的代码，这样会缺乏一个 全局视角去看待所有仓库 ，无法清晰的了解到整个项目的结构和状态。当不同仓库中的有依赖关系时，会有依赖管理的问题，时时刻刻都需要注意依赖的版本和代码是否一致例如：主项目依赖了子项目提供的组件，如果此时子项目在某次升级时产生了 breaking change 版本，和原来的组件使用方式完全不一致，这样主项目就直接寄了。因此我们需要在子项目更新时，及时的更新主项目的依赖，这样就形成了负担。当组件出现异常时，就会开始怀疑包版本是否不正确...因此，Polyrepo 需要有合适的版本管理方法，保证版本和代码的一致性3. 工具混乱每个项目都使用自己的一组命令来运行测试、构建、服务、检查、部署等。不一致会导致记住在不同项目中使用哪些命令的心理负担。单独部署、独立工作流在多仓库情况下，由于在不同的仓库里，继承测试会变得更加的困难，需要很多步骤才能组合在一起测试。同时每个项目都使用自己的一组命令来运行测试、构建、服务、检查、部署等。不一致会导致记住在不同项目中使用哪些命令的心理负担，维护起来也是问题例如：每个项目都会配置自己的 webpack.config.js 文件，但是这些配置很多都是一致的。例如：CI、CD 流程很难将多个仓库组合到一起去，测试也比较难处理4. 代码共享要跨存储库共享代码，可能会为共享代码创建一个存储库。那么必须设置工具和 CI 环境，将提交者添加到存储库，并设置包发布以便其他存储库可以依赖它。让我们不要开始跨存储库协调第三方库的不兼容版本....5. 代码管理基于 Polyrepo 管理，每个开发人员的 commit 都会提交到各个仓库，不利于代码的 CR，也不利于管理代码合入机制，导致容易出现逃逸现象。Monorepo 能解决什么问题？Monorepo 都能解决以上的问题不仅仅在于减少了维护和管理多个代码库的成本，同时还有以下优点：代码复用 ：因为多个项目共享一个代码库，所以避免了在不同项目中重复编写相同功能代码的问题，提高了开发效率。提升协作效率 ：多个项目在同一个代码库中进行开发，可以方便地共享代码和文档，避免不同项目之间的沟通和协调成本。集中管理 ：Monorepo 架构中，不同的应用程序都在同一个代码库中，方便管理和监控。这一点非常重要，特别是在需要同时对多个版本进行修改和维护的情况下。统一构建 ：Monorepo 的一个重要特点是可以共用一套构建系统和 工具链进行构建和部署 ，提升了构建的效率。可以快速定位问题 ：由于所有的代码都在同一个代码库中进行开发，debugger 可以很快找出问题所在的代码文件和行数，便于开发人员调试问题。一个版本 ：无需担心因为项目依赖于第三方库的冲突版本而导致的不兼容问题。Monorepo 和 Polyrepo 的不同？前面说完之后，相信这里已经有了答案，这里再用对比的方式来看看 Monorepo 和 polyrepo 相比之下都有哪些优势依赖安装PolyrepoMonorepo每个项目都需要安装在主项目安装这里就可以看到 Monorepo 的优势在于只需一次安装，即可安装所有子项目的依赖，而 polyrepo 需要每个都单独 installCI 构建PolyrepoMonorepo各自构建增量构建：根据依赖关系（DAG），通过拓扑排序并行构建此次改动涉及到的项目Monorepo 的优势在于能够通过有向无环图，清楚的知道依赖关系，并通过拓扑排序进行增量的构建，而 Polyrepo 也有一定的优势在于无更改就不需要重新打包同时在本地开发调试、上线部署上还有着很大的优势...Monorepo 如何搭建？最简单的方式是采用 pnpm + workspace 来搭建，不需要其他额外的工具，利用 pnpm 提供 filter 命令，基本可满足大部分场景。搭建方式也很简单可以├── packages | ├── pkg1 | | ├── package.json | ├── pkg2 | | ├── package.json ├── package.json ├── pnpm-workspace.yaml在 pnpm-workspace.yaml 中指定 packages 的入口packages: # all packages in subdirs of packages/ and components/ - 'packages/**'在 pkg1 中引用 pkg2 只需要在 pkg1 的 package.json 中的依赖中进行添加"dependencies": { "@test/utils": "workspace:^1.0.0" }在根的 package.json 中通过 filter 配置 script 来指定特定的项目执行"build:pkg1": "pnpm --filter=@pkg1 build",当你的项目需要根据 package.json 下的依赖进行拓扑排序并行打包、增量打包、打包缓存、依赖图可视化等等各种功能时，你可以考虑一些构建工具比如 Rush、lerna、Turbo，这些都能高效的帮你完成想要的工作Monorepo的弊端：用Monorepo之前想一想：1.团队能否驾驭？大仓开发意味着团队每个人有全部项目的代码权限，严格的CR机制，自动化构建、测试流水线、团队整体代码水平这些都得有一定规模和经验。试想某个周五的下午一个公司新人提交了一段神秘代码，然后周末整个公司的项目全都挂了2.产出是否步调一致？无论是react还是vue他们采用Monorepo更多是因为他们产出步调一致并且存在相互关联场景，合在一起也能作为一个单独的项目发布。但是在普遍的业务项目开发中产出基本都是独立的项目，没有太多联系。例如，多个项目共用一个组件，如果有一天项目A对该组件有独特需求，这个需求是破坏性的，这时候如果步调不统一，其他项目怎么办？要么其他项目被迫升级，要么公共组件库粘贴复制一份用来满足这个独特需求。Monorepo还是更适合框架、工具类库开发，业务项目最好还是独立代码仓库的好。最后Monorepo 和 Polyrepo 都有各自的好处，项目不大就用 Polyrepo 也不会有什么坏处，当项目复杂度越来越高时，就要开始考虑做 Monorepo 整合了，让团队的工作更加聚合，让代码更加聚合。
做后台项目区别于做其它的项目，权限验证与安全性是非常重要的，可以说是一个后台项目一开始就必须考虑和搭建的基础核心功能。在后台管理系统中，实现权限控制可以采用多种方案：权限方案类型描述基于角色的访问控制（Role-Based Access Control，RBAC）是一种广泛采用的权限控制方案。系统中定义了不同的角色，每个角色具有一组权限，而用户被分配到一个或多个角色。通过控制用户角色的分配，可以实现对用户访问系统中不同功能和资源的权限控制。基于权限的访问控制（Permission-Based Access Control）这种方案将权限直接分配给用户，而不是通过角色来管理。每个用户都有自己的权限列表，控制用户对系统中各项功能和资源的访问。基于资源的访问控制（Resource-Based Access Control，RBAC）这种方案将权限控制与资源本身关联起来。系统中的每个资源都有自己的访问权限，用户通过被授予资源的访问权限来控制其对资源的操作。层次结构权限控制（Hierarchical Access Control）这种方案基于资源和操作的层次结构来进行权限控制。系统中的资源和操作被组织成层次结构，用户被授予访问某个层次及其子层次的权限。基于规则的访问控制（Rule-Based Access Control）这种方案使用预定义的规则来确定用户对系统中功能和资源的访问权限。规则可以基于用户属性、环境条件或其他因素进行定义。这里我选择了基于角色的访问控制（Role-Based Access Control，RBAC） 这是因为RBAC提供了一种灵活且易于管理的方式来控制用户对系统功能和资源的访问，也是目前最主流的前端权限方案选择。在RBAC中，系统中的功能和资源被组织成角色，而用户则被分配到不同的角色。每个角色都有一组权限，定义了该角色可以执行的操作和访问的资源。通过给用户分配适当的角色，可以实现对用户的权限控制。RBAC的好处之一是它简化了权限管理的复杂性。管理员只需管理角色和分配角色给用户，而不需要为每个用户单独定义权限。当需要对用户的权限进行修改时，只需调整其角色的权限即可。此外，RBAC还支持灵活的权限组合，允许创建具有不同权限组合的角色，以适应不同用户的需求。它也便于扩展，可以随着系统的发展和需求的变化而调整和添加角色。2.RBAC下的权限字段设计与管理模型1.用户权限授权是对用户身份认证的细化。可简单理解为访问控制，在用户身份认证通过后，系统对用户访问菜单或按钮进行控制。也就是说，该用户有身份进入系统了，但他不一定能访问系统里的所有菜单或按钮，而他只能访问管理员给他分配的权限菜单或按钮。主要包括：Permission（权限标识、权限字符串）：针对系统访问资源的权限标识，如：用户添加、用户修改、用户删除。Role （角色）：可以理解为权限组，也就是说角色下可以访问和点击哪些菜单、访问哪些权限标识。权限标识或权限字符串校验规则：权限字符串：指定权限串必须和菜单中的权限标识匹配才可访问权限字符串命名规范为：模块:功能:操作，例如：sys:user:edit使用冒号分隔，对授权资源进行分类，如 sys:user:edit 代表 系统模块:用户功能:编辑操作设定的功能指定的 权限字符串与当前用户的 权限字符串进行匹配，若匹配成功说明当前用户有该功能权限还可以使用简单的通配符，如 sys:user:*，建议省略为 sys:user（分离前端不能使用星号写法）举例1 sys:user 将于 sys:user 或 sys:user: 开头的所有权限字符串匹配成功举例2 sys 将于 sys 或 sys: 开头的所有权限字符串匹配成功 这种命名格式的好处有：可读性和可理解性 ：使用模块、功能和操作的格式可以直观地表达权限的含义。每个部分都有明确的作用，模块表示特定的模块或子系统，功能表示模块内的某个功能或页面，操作表示对功能进行的具体操作。通过这种格式，权限名称可以更容易地被开发人员、管理员和其他人员理解和解释。可扩展性和灵活性： 通过使用模块、功能和操作的格式，可以轻松地扩展和管理权限。每个模块、功能和操作都可以被单独定义和控制。当系统需要增加新的功能或操作时，可以根据需要添加新的权限字符串，而不需要修改现有的权限规则和代码。细粒度的权限控制： 这种格式支持细粒度的权限控制，可以针对特定的功能和操作进行权限管理。通过将权限名称拆分为模块、功能和操作，可以精确地定义哪些用户或角色具有访问或操作特定功能的权限。避免权限冲突： 使用模块、功能和操作的格式可以避免权限之间的冲突。不同模块、功能和操作的权限名称是唯一的，这样可以避免同名权限之间的混淆和冲突。2.权限管理模型关键数据模型如下：用户：登录账号、密码、角色角色：角色名称、角色权限字符、对应菜单、对应菜单下的权限菜单：菜单名称、菜单URL、菜单类型用户角色关系：用户编码、角色编码角色菜单关系：角色编码、菜单编码关系图如下：【用户】 <---多对多---> 【角色】 <---多对多---> 【菜单/权限】3.实现思路与步骤前端权限一般分为路由级权限和按钮级权限，这里我们先实现页面路由级的权限功能，按钮级的会在后面讲到。大致的思路如下图：上图是用户从 登录-->路由守卫-->权限验证-->构建路由表-->跳转目标页面的一个简单的正向流程，可以看到，权限验证和 构建路由表这两步是发生在 路由守卫这一步里，而实际上在开发设计阶段，我们还要做的准备有：与后端确认权限字段及类型确定路由表信息是由前端还是后端生成为了方便理解，我们就按照这个正向流程来逐步实现，期间需要用到或者提前考虑设计到的内容，包括以上需要准备的两点，我会补充在步骤当中。1.登录登录成功后，获取到token,将token存到本地的sessionStorage里。async login({ commit }, userInfo) { const { data } = await login(userInfo) const accessToken = 'Bearer ' + data.access_token if (accessToken) { sessionStorage.getItem(tokenTableName) } else { message.error(`登录接口异常，未正确返回${tokenName}...`) } }接着，进行路由跳转到首页import { useRouter } from 'vue-router'; const router = useRouter(); router.push('/');如果考虑到页面token失效后，重新登陆后返回原先的路由地址，则需要在路由守卫中添加 redirect字段用来存储当前的路由地址 next({ path: '/login', query: { redirect: to.fullPath }, replace: true })然后在登录页中，监听路由对象中的这个值，如果有值，那么在刚刚路由跳转时，就跳转到该路由地址，而非 /首页，另外，还需要考虑到403，404页面。所以，添加了这些逻辑后，部分代码如下：//login.vue import { ref, watch, useRouter } from 'vue'; import { useRouter } from 'vue-router'; const redirect = ref('/'); const router = useRouter(); watch( () => router.currentRoute.value.query.redirect, (redirectValue) => { redirect.value = redirectValue || '/'; }, { immediate: true } ); function handleRoute() { return redirect.value === '/404' || redirect.value === '/403' ? '/' : redirect.value }, async handleSubmit() { loading.value = true try { await login(form.value) loading.value = false router.push(handleRoute()); } catch { loading.value = false } }, 2.路由守卫与权限校验当进行路由跳转时，会进入到路由守卫中，在路由守卫中：路由守卫会首先判断有没有token，如果没有，先判断要去的路由地址是否包含在路由白名单内，如果要去的路由地址也不在路由白名单内，就会让用户跳转到登录页重新登陆。如果有token，会先判断跳转的目标地址是否是登录页，如果是，则重新跳转到默认首页此时，我们就需要对用户权限进行校验，首先，判断当前用户是否 拥有角色信息,如果没有，就要获取用户的角色信息。3.获取角色信息与权限信息调取用户信息接口获取用户角色信息和权限信息,代码如下：//store/user // 获取用户信息 GetInfo({ commit, dispatch }) { return new Promise((resolve, reject) => { getUserInfo() .then(async (response) => { const result = response.data if (result.roles && result.roles.length > 0) { // 验证返回的roles是否是一个非空数组 commit('SET_ROLES', result.roles) //permissions就是对应的用户权限信息 commit('SET_PERMISSION', result.permissions) } else { // 如果当前用户没有角色，则赋值一个默认角色 commit('SET_ROLES', ['ROLE_DEFAULT']) } resolve(result) // GetInfo一旦失败就说明这个token不是过期就是丢失了,直接走catch并让调用方跳转路由 if (!response.success) { reject(response.errorMsg) } }) .catch((error) => { reject(error) }) }) },根据之前上文提到的权限管理模型，从之间的关系是多对多，因此，role(角色）和 permssions(权限）的类型应为数组，其中的权限permssion这个字段的格式规范也在上文提及。在与后端约定好后，后端返回的信息如图：4.谁来生成动态路由表？在成功获取到用户信息，拿到用户角色和对应权限后，此时，就需要根据权限生成对应的路由表了，到这里，我们需要思考一个问题： 路由表是由后端提供还是由前端提供？ A:前端根据权限生成路由表B:后端生成路由表给前端没错！答案是C：路由表可以由后端提供或由前端提供。两者的优劣分别是：1、后端提供路由表：优点 ：安全性高：后端负责验证和控制权限，可以确保只有授权的用户能够访问特定的路由和功能。隐藏敏感信息：后端可以根据用户的角色和权限隐藏不应该被访问的敏感路由和数据。适用于复杂的权限规则：后端可以使用更复杂的逻辑和规则来处理权限控制，例如基于用户角色、用户组、权限等的复杂控制逻辑。缺点：前后端耦合度高：由于路由表由后端提供，前端开发人员可能需要与后端开发人员密切协作，增加了协调和沟通的成本。前端依赖后端：前端应用程序可能需要等待后端提供路由表后才能进行开发和测试，增加了开发的时间和依赖性。2、前端提供路由表：优点：前后端解耦：前端可以独立开发和维护路由表，减少了与后端的依赖性和协调成本。更好的用户体验：前端可以根据用户的角色和权限动态展示路由和功能，提供更灵活和个性化的用户体验。缺点：安全性较低：前端提供的路由表容易受到篡改和绕过，安全性相对较低。隐藏敏感信息的难度增加：前端无法直接隐藏敏感路由和数据，需要依赖后端接口的授权验证来保护敏感信息。最终，考虑到后台管理系统的安全性优先级最高，我选择了由后端存储，并生成返回路由表信息。 确定好了之后，你就会遇到一个坑：后端提供的路由表无法直接在前端路由中添加使用 这是因为，后端存储的路由表的结构只一个JSON对象。怎么解决，我们放到后面讲。5.公共路由和动态路由我们现在把整个路由分为两个部分，分别是 公共路由和 动态路由（私有路由）。公共路由公共路由顾名思义就是无论当前用户是什么角色，都会出现的路由部分。一般这样的路由分别有：首页驾驶舱、登录注册页、403页、404页。 这部分路由是在前端项目中提前写死的。 以我的项目为例，我在src/config下创建一个 publicRouter.js文件，然后里面放入基础路由信息：//基础公共路由 export const constantRouterMap = [ { path: '/', name: '', component: () => import('@/layout'), redirect: '/homePage', children: [ { path: '/homePage', name: 'homePage', component: () => import('@/views/homePage/index.vue'), }, ], }, { path: '/login', component: () => import('@/views/login'), }, { path: '/403', name: '403', component: () => import('@/views/403'), }, { path: '/:pathMatch(.*)*', component: () => import('@/views/404'), }]这里面都是路由懒加载的写法，这个没啥好说的。需要注意的是在vue3中，vue-router的版本是4.x以上，在跳转404页面时，如果你的写法是{ path: "/404", name: "notFound", component: () => import('@/views/404') }这样写会提示报错，这是因为vue-router4.x的版本官方推荐引入了这种新写法，配置一个通配符路由，匹配所有未被其他具体路由匹配的路径。其中 :pathMatch 是参数名，而 (.*)* 是参数的匹配模式，它使用正则表达式来匹配任意路径。动态路由(私有路由）上文提到的后端返回给前端当前用户的路由表信息，其实就是私有路由，这部分的路由是动态的。那么我们只需要把 公共路由+动态路由=当前角色用户完整路由表。然鹅， 后端返回给前端的动态路由是无法直接添加到当前页面路由中的 ，这是因为在浏览器的前后端请求当中，信息的返回体都是以JSON字符串的数据交换格式进行传输，而JSON字符串是不支持函数的。为什么要提到函数形式呢？因为当我们在路由表使用懒加载的写法时，component的值是一个异步函数。也只有异步函数才能实现对路由的异步懒加载。import（）会返回一个promise，这个 promise 最终会加载对应的组件模块。在格式上表现为一个func函数，因此，我们无法将compnent的值传给后端存储但是办法总比困难多，我们可以将对应的路径字符串传给后端存储，然后再通过后端返回的路径字符串转换成这种箭头函数的写法。除了这个需要转换以外，我们还要考虑一些问题：比如，转换后的路由结构要符合router中的路由格式，否则会在调用router.addRoutes时报错，添加失败。添加一些其他自定义属性（例如添加导航栏菜单图标、某个路由的显隐、路由缓存、跳转外链接、...），以满足特定需求。以我项目中的的路由示例，直接上代码看：{ "path": "/", //path,路由的路径，即访问该路由时的 URL 路径。 "name": "homePage",//name,路由的名称，用于在代码中标识路由。通常用于编程式导航。 "hidden": false,//hidden,是否隐藏路由，在Vue Router 4.0 中被废弃。 "component": "Layout"//路由对应的组件，可以是通过懒加载方式导入的异步组件，或直接引入的同步组件。 "meta": {//meta,路由元信息，可以用于存储一些额外的信息，比如页面标题、权限等 "title": "首页", //路由标题 "icon": "icon-Home", //路由图标 "target": "", //是否跳转新页签 "permission": "homePage",//权限 "keepAlive": false//是否缓存 }, "redirect": "/homePage",//重定向 "fullPath": "/",//完整的url路径，会带上？后面的参数 "children": [//子路由 { "path": "/homePage", "name": "homePage", "meta": { "title": "首页", "icon": "", "target": "", "permission": "homePage", "keepAlive": false }, "fullPath": "/homePage" } ] }考虑完这些之后，我们就开始拿着后端返回的路由表信息动手转换吧！ 先看看后端返回给前端的路由表格式内容这里你只需要注意component这个字段的值就行了。可能有人会问： “那我给后端传的路由表是什么格式的，也是这样吗？” 答案是：当然不是！RBAC权限方案中，用户可以自己配置权限，也就是会有对应的用户管理，角色管理，菜单（权限）管理这三个基本的模块，给后端传的是对应模块的修改配置内容。前端是不需要关心传给后端什么格式。 有点啰嗦了，总之，就是你指着上文这三张图片，让后端给你生成出来就完事了。后端要是说办不到，那就是后端的问题。6.RBAC的权限配置项上文提到了三个基本的配置模块，分别是 用户管理，角色管理，菜单（权限）管理,按照先后顺序，我们首先要新建一个权限，在页面级的权限中，权限是跟每个页面路由一一对应的，所以当我们需要新建一个权限的时候，也就是在新建一个页面路由。而为了方便知道所有页面权限之间的层级关系，最终的展现形式就是一个 树形表格结构 ：可以看到，在页面路由（权限）配置项里，单个路由的各个都可以进行修改配置，这就让整个项目的路由表的可维护性和灵活度都极大的提高。需要注意的是 路由地址和 组件路径这两个配置项，路由地址可以随意命名，但是 同层级下，必须唯一 。而组件路径里面的值就是 相对src目录的相对路径 。比如我新建一个部门管理页面权限，一般在前端路由中的懒加载写法是这样：component: () => import('@/views/login'),但在这个配置项中，只需要填入login这个字段就行了。原因是因为前面的这些字段会在后面对路由的格式化中进行处理。虽然后续步骤会讲到这一步，但这里我还是先贴出来，方便你理解：component: constantRouterComponents[item.component || item.key] || (() => import(`@/views/${item.component}`).catch(() => import('@/views/404') )),那有人就会问，如果我乱填一个不存在的组件路径地址怎么办？正常情况下这样会导致整个动态路由在构建过程中报错，从而导致白屏。所以，上述代码里的 .catch会捕获这个错误，然后跳转到404页面，再从404页面跳转到首页。当我们配置好权限后，会将新增的路由配置信息发送给后端， 后端根据这些配置信息，会在对应的路由表中添加这个路由，并生成新的路由表返回给前端 。那当我们有一个权限之后，该怎么配置给对应的用户呢？还记得我上文提及的RBAC的权限管理模型嘛：就是按照关系图中，我们将权限分配给角色，所以，就要有 角色管理这个模块来进行分配：用户管理也是依葫芦画瓢，跟 角色管理如出一辙，碍于篇幅，就不再过多赘述。每一个配置项都可以根据你的业务场景和需要，进行增减。7.获取动态路由（私有路由）并转换言归正传，接下来，这一步是整个前端权限中最重要的一步。在src/router下，我们新建一个generatorRouters.js文件。里面的内容是：//getCurrentUserNav获取动态路由的接口 import { getCurrentUserNav } from '@/api/user' //validURL是一个正则判断方法，用来校验当前字符串是否符合url外链格式 import { validURL } from '@/utils/validate' // 前端路由表 const constantRouterComponents = { // 基础页面 layout 必须引入 Layout: () => import('@/layout'), 403: () => import('@/views/403'), 404: () => import('@/views/404'), } /** * 动态生成菜单 * @param token * @returns {Promise<Router>} */ export const generatorDynamicRouter = (token) => { return new Promise((resolve, reject) => { getCurrentUserNav() .then((res) => { //接收后端返回的路由表，结构是个数组 let menuNav = res.data //将路由表存到本地临时缓存中 //这一步是为了刷新的时候不需要再调接口，增加用户体验的，没这方面需求可以不用写 sessionStorage.setItem('generateRoutes', JSON.stringify(menuNav)) //转化路由格式 const routers = generator(menuNav) resolve(routers) }) .catch((err) => { reject(err) }) }) } /** * 格式化树形结构数据 生成 vue-router 层级路由表 * * @param routerMap //当前路由表route * @param parent//当前路由表route的父级component * @returns {*} */ export const generator = (routerMap, parent) => { return routerMap.map((item) => { const { title, show, hideChildren, hiddenHeaderContent, icon, hidden } = item.meta || {} if (item.component) { // Layout ParentView 组件特殊处理 //这里是对父组件/布局组件的处理，因为有可能出现嵌套布局和多种布局的情况，这个时候需要进行处理。 if (item.component === 'Layout') { item.component = 'Layout' } else if (item.component === 'ParentView') { item.component = 'BasicLayout' item.path = '/' + item.path } } if (item.isFrame === 0) { item.target = '_blank' } const currentRouter = { // 如果路由设置了 path，则作为默认 path，否则 路由地址 动态拼接生成如 /dashboard/workplace path: item.path || `${(parent && parent.path) || ''}/${item.key}`, // 路由名称，建议唯一 // name: item.name || item.key || '', name: item.name || item.key || '', // 该路由对应页面的 组件 :方案1 // 该路由对应页面的 组件 :方案2 (动态加载) //这里就是将component的字符串值转换成懒加载的异步函数 //同时，如果当前路径并没有对应的组件，catch捕获报错，然后跳转到404页，这一步很重要，否则 component: constantRouterComponents[item.component || item.key] || (() => import(`@/views/${item.component}`).catch(() => import('@/views/404') )), hidden: item.hidden, // redirect: '/' + item.path || `${parent && parent.path || ''}/${item.key}`, // meta: 页面标题, 菜单图标, 页面权限(供指令权限用，可去掉) meta: { title: title, icon: icon, hiddenHeaderContent: hiddenHeaderContent, target: validURL(item.path) ? '_blank' : '', permission: item.name, keepAlive: false, hidden: hidden, }, //适配本框架的跳转路径 } // 是否设置了隐藏菜单 if (show === false) { currentRouter.hidden = true } // 修正路径，而antdv-pro的pro-layout要求每个路径需为全路径 //这一步的修正路径也是因人而异，正常情况下按照我这样拼接就没问题了 if (!constantRouterComponents[item.component || item.key]) { if (parent && parent.path && parent.path !== '/') { currentRouter.path = `${parent.path}/${item.path}` } } // 是否设置了隐藏子菜单 if (hideChildren) { currentRouter.hideChildrenInMenu = true } // 重定向 item.redirect && (currentRouter.redirect = item.redirect) //添加fullPath currentRouter.fullPath = currentRouter.path // 是否有子菜单，并递归处理 if (item.children && item.children.length > 0) { currentRouter.children = generator(item.children, currentRouter) } return currentRouter }) }generatorDynamicRouter方法（promise嵌套）上面的代码有点多，不要觉得麻烦，其实就两个方法，第一个方法 generatorDynamicRouter会调取后端接口， 获取后端返回的私有路由表信息 ，返回的是个 promise对象，因为需要调接口请求后端数据，并且其他方法依赖这个方法的接口返回值，所以是个异步函数。之所以这么写的另外一个原因，是因为在它之前还有个promise异步方法包着它。我们都知道：在嵌套的 Promise 中，内部的 Promise 会先执行，然后再执行外部的 Promise。这是由于 JavaScript 的异步执行机制所决定的。 所以这种套娃式的目的只有一个—— 就是确保外层的异步方法在调用内部异步方法时，能够保证拿到内部异步方法请求成功之后的返回值 （也就是将异步方法变成同步方法，这也是Promise主要的作用之一，面试常问的）。而最外层的异步方法，是放在路由守卫当中直接调用的，由于我们是动态异步加载路由，那么执行的方法肯定也是异步。具体的原因是因为：如果动态加载路由的方法不是异步的，那么在路由守卫中使用它将导致它立即执行并返回一个 Promise，而不是在路由导航过程中延迟加载。这样会使得在路由守卫中的逻辑无法按预期执行，因为在组件加载完成之前，它依赖的组件可能还没有被加载，导致出现错误或不一致的状态。从后端获取路由配置往往涉及到网络请求和异步操作，不能保证立即返回所有路由信息。当你从后端获取到路由配置后，需要将其添加到 Vue Router 中，以便在前端应用程序中进行动态路由。在这个过程中，你需要使用异步操作来确保在获取到路由配置后再添加到路由中。generator方法（递归转化）言归正传，generatorDynamicRouter方法调取后端接口，拿到后端返回路由表信息后，会紧接着调用第二个 generator方法，并将路由表信息当做参数传入。而 generator是一个递归方法。这个也很好理解，因为路由信息里都会包含children子路由，需要层层递归，generator内部会对每一个路由进行 逐层转化 ，每一步都在代码中标有注释，我就不再过多赘述了。我们只需要知道：generator的最终目的就是将后端返回的路由表结构转化成符合前端路由格式的路由（听起来有点绕）。附上格式化好之后的路由信息：再对比下格式化之前的样子：可以看到component从字符串变成了函数，path路径也得到了拼接补充。8.存储格式化好的动态路由及导航栏菜单信息上文提到在generatorDynamicRouter异步方法之外还有一层异步方法，其实我是放在了vuex的store下某个模块中的actions里（默认大家都会用vuex）。具体代码如下const actions = { GenerateRoutes({ commit }) { let sidebarList = [] return new Promise((resolve) => { generatorDynamicRouter().then((routers) => { //sidebarList是侧边栏渲染数据 sidebarList = routers //routers就是我们要存储到vuex中的动态路由 commit('set_routers', routers) //侧边栏数据存到vuex中 commit('set_sidebarList', sidebarList) resolve() }) }) }, }9.添加动态路由好了，到这一步，我们就拿到了准备好的 公共路由和 私有路由。这个时候，我们再回到路由守卫，回顾一下之前的流程：路由守卫先判断token没有token去登录拿token（或者是直接去了白名单里）拿到token后判断有没有角色信息，没有角色信息就去请求角色信息（调接口）拿到角色信息后，再去获取对应角色信息的路由表（调接口）将后端返回的路由表信息转换成前端能添加的路由表信息格式添加路由跳转对应路由页面现在，我们来到了第6步，添加路由，在添加路由之前，先看下路由守卫：路由守卫的代码如下，在src/config目录下新建文件 permissions.js import router from '@/router' import store from '@/store' import getPageTitle from '@/utils/pageTitle' import getInfoRouter from '@/router/getInfoRouter' import { loginInterception, recordRoute, routesWhiteList } from '@/config' router.beforeEach(async (to, from, next) => { let hasToken = store.getters['user/accessToken'] if (hasToken) { if (to.path === '/login') { next({ path: '/' }) } else { //权限校验 if (store.getters['user/roles'].length === 0) { try { //获取用户信息，包括角色信息和权限信息 await store.dispatch('user/GetInfo') //获取动态路由表信息，并对路由表进行格式转化，然后存到vuex中 await store.dispatch('async-router/GenerateRoutes') //从vuex中拿到动态路由表数据 let accessRoutes = store.getters['async-router/addRouters'] //循环添加路由 accessRoutes.forEach((route) => { //isHttp方法用来判断是否是外链，如果是外链，就不添加到当前路由表中 if (!isHttp(route.path)) { router.addRoute(route) // 动态添加可访问路由表 } }) //刷新跳转 next({ ...to, replace: true }) } catch (e) { //登出 await store.dispatch('user/Logout') //记录登出前的路由地址 next({ path: '/login', query: { redirect: to.fullPath } }) } } else { next() } } } else { //判断是否在白名单中 if (routesWhiteList.indexOf(to.path) !== -1) { next() } else { //记录跳转到登录页之前的路由地址 next({ path: '/login', query: { redirect: to.fullPath }, replace: true }) } } }) router.afterEach((to) => { //浏览器页签标题 document.title = getPageTitle(to.meta.title) })在vue-router4.x版本中 ，往当前的路由 router对象中动态添加路由时，需要使用官方提供的 addRoute方法，不能直接对router对象进行修改，这是因为vue-router必须是要vue在实例化之前就挂载上去的。addRoute方法传入的参数是 单个路由对象 ,所以写法上我们需要对动态路由数组进行for循环，在内部调用 addRoute方法，从而达到成功添加多个动态路由的目的。这里附上官方API地址链接：router.vuejs.org/zh/api/inte…添加完路由之后，调用 next({ ...to, replace: true }),就会成功跳转到目标路由地址了。10.登出当用户要退出时，也就是登出，我们只需要调用登出接口，并在接口成功返回后，重置vuex中的用户信息（token,角色roles,权限permission），同时清空本地缓存的数据就行了。附上代码：/** * @description 退出登录 * @param {*} { dispatch } */ Logout({ commit, dispatch, state }) { return new Promise((resolve, reject) => { logout() .then(async () => { await dispatch('resetAll') //清空导航tab页签 this.dispatch('tagsBar/delAllVisitedRoutes', { root: true }) resolve() }) .catch((error) => { reject(error) }) .finally(() => {}) }) }, /** * @description 重置accessToken、roles、permission等 * @param {*} { commit, dispatch } */ async resetAll({ dispatch, commit }) { await dispatch('setAccessToken', '') commit('SET_ROLES', []) commit('SET_PERMISSION', []) sessionStorage.clear() },到这一步，整个前端权限的流程就算是走完了。4.按钮级权限前端按钮级权限，是指在前端界面中，根据用户的权限不同，对不同的按钮进行权限控制。这样做的目的是为了确保系统的安全性和数据的保密性，使得不同用户只能执行其有权执行的操作，从而避免潜在的安全风险。上面这段定义是我copy过来的，其中这句 对不同的按钮进行权限控制并不完全对， 除了按钮，比如页面中的某个字段，某个div，某个组件要求根据当前用户的权限进行控制时，都可以称为按钮级权限 。像这种按钮级权限的设计方案很多，在vue中，本人目前知道的主流方式有两种：条件渲染（Conditional Rendering）： 这是一种简单有效的方法，通过在前端代码中根据用户的权限信息来决定是否渲染特定的按钮或组件。比如，你可以使用条件语句（如 v-if、ngIf等）来判断用户是否有权限，从而决定是否渲染按钮。指令/组件封装： 使用一些前端框架（如Vue、React、Angular）提供的自定义指令或组件，可以封装权限控制逻辑。你可以创建一个自定义指令或组件，接受用户权限作为输入，然后根据权限来决定是否显示按钮。一开始我用的是指令封装的方式，但是随着业务需要，条件渲染这种方式我也用了。在开始看具体的实现方法之前，不知道你有没有留意到上文的路由守卫中，只是根据用户的角色字段 role来校验当前用户的权限，还有一个字段 permssions(权限)其实一直没有派上用场，但是无论是在权限的配置项中，还是在后端的返回字段里，都有它的存在：1.指令/组件封装形式的按钮级权限现在它的作用来了，先看指令的具体实现方式，我们在src/directive目录下新建一个 hasPermi.js文件，代码如下：/** * v-hasPermi 操作权限处理 */ import store from '@/store' export default { //`el`（element）：这是指令所绑定的元素，是一个普通的DOM元素。在自定义指令的钩子函数中, //你可以通过操作`el`来实现对元素的各种修改，比如添加、删除、修改样式等。 /** `binding`：这是一个包含指令信息的对象。它包括了以下属性： `name`：指令的名称，不包括`v-`前缀。 `value`：指令的绑定值，即传递给指令的参数。在你的示例中，`value`存储了操作权限的标识符数组。 `oldValue`：先前的绑定值，仅在组件更新时可用。 `expression`：指令的表达式，如`v-my-directive="someValue"`中的`someValue`。 `arg`：指令的参数，如`v-my-directive:arg="value"`中的`arg`。 `modifiers`：一个包含修饰符的对象，如`v-my-directive.modifier`。 `vnode`：渲染该组件的虚拟节点。 */ mounted(el, binding, vnode) { const { value } = binding //all_permission代表所有权限，一般超级管理员需要用这个 const all_permission = '*:*:*' //获取后端返回的权限字段列表 const permissions = store.getters && store.getters['user/permissions'] //校验当前用户的权限列表是否存在 if (value && value instanceof Array && value.length > 0) { //传入的参数，需要校验的权限字段值 const permissionFlag = value //判断当前权限列表中，有无该传入的权限字段值 const hasPermissions = permissions.some((permission) => { return ( all_permission === permission || permissionFlag.includes(permission) ) }) if (!hasPermissions) { // 如果用户没有权限，移除当前元素的父节点（即移除当前元素） el.parentNode && el.parentNode.removeChild(el) } } else { throw new Error(`请设置操作权限标签值`) } }, }这段代码主要是在页面元素的挂载阶段（mounted钩子）检查用户权限，如果用户没有操作权限，那么就移除对应的页面元素。具体的代码说明我都放在里注释里。 写好了这个方法后，需要将该指令挂载到全局当中，这个时候需要进入 main.js文件，在vue3中，给vue对象挂载自定义指令的方式如下：import { createApp } from 'vue' import App from './App' import directive from '@/directive' // directive const app = createApp(App) app.mount('#app') // 添加到全局 directive(app)然后，在你需要进行按钮级权限校验的地方添加上这段指令，比如：<a-button type="primary" @click="openModal('add')" v-hasPermi="['system:user:add']" > 新增 </a-button>上面这段代码里的 system:user:add意思是 系统目录下用户管理模块的新增按钮权限 （如果看不懂就翻到文章最开始介绍RBAC的权限字段设计那里），当后端返回的当前用户数据中的permissions字段里，没有 system:user:add这个权限字段时，这个按钮就不会显示出来。2.条件渲染形式的按钮级权限自定义指令实现的按钮级权限并不能满足我的业务需要，由于vue的限制，我们的自定义指令只能用在组件的 template模块内，也就是只能放在dom标签上。但是有的时候，我可能需要对某个字段的逻辑，或者根据角色的不同，显示不同的表格列，这是数据上的处理，这个时候自定义指令就毫无用武之地。所以就需要用到条件渲染的形式。 条件渲染的形式，说白了，就是封装一个 全局方法，方法返回值是 true或者是 false，然后再加一个 v-if，但是我其实用不到这个 v-if,我只需要这个判断权限的返回结果就足够了，核心逻辑跟自定义指令如出一辙。就不再过多赘述，直接上代码你就懂了：/** * checkPermi 操作权限处理 */ import store from '@/store' export default function checkPermi(value) { const all_permission = '*:*:*' const permissions = store.getters && store.getters['user/permissions'] const permissionFlag = value const hasPermissions = permissions.some((permission) => { return all_permission === permission || permissionFlag.includes(permission) }) return hasPermissions }跟上面的自定义指令不能说完全一样，只能说是一模一样。然后，我们就需要把这个方法挂载到全局，这里的挂载方式是 vue3的：import { createApp } from 'vue' import App from './App' const app = createApp(App) app.config.globalProperties.$checkPermi = checkPermi app.mount('#app')然后在你需要用的地方引入：... import { ref, reactive, onMounted, watch, toRefs, nextTick, getCurrentInstance, } from 'vue' const { $checkPermi } = getCurrentInstance().appContext.config.globalProperties if ($checkPermi(['ProductionManagement:CustomOrderManagement:pro'])) { //有这个按钮级权限时的处理 }else{ //没有这个按钮级权限时的处理 } 怎么样，是不是很简单？5.遇到的坑这里面有几个坑需要注意下：第一个坑就是在 vue3支持的vue-router4.0版本之前，也就是 vue2中，动态添加路由的方式支持的是 addRoutes，它的参数是格式是一个 数组。而到了4.0后，addRoutes这个方法被废弃，取而代之的是 addRoute，它的参数则是一个路由对象。这两个方法无论是在 传参类型还是添加相同path时的覆盖逻辑都不相同。第二个坑就是无论是用控制台还是打断点的方式，或者是用Vue.js devtools的谷歌插件，都无法在路由守卫中获取到添加完成后的最新router对象， 也就是说你会在调试addRoute加载后的动态路由表时，发现与之前为添加路由时的路由表是一样的，从而无法判断是否动态路由添加成功 （就很蛋疼）第三个坑是 next({ ...to, replace: true })这个方法，当添加好动态路由后，如果不这么写next，会白屏。 在 addRoute()之后第一次访问被添加的路由会白屏，这是因为刚刚 addRoute()就立刻访问被添加的路由，然而此时 addRoute()没有执行结束，因而找不到刚刚被添加的路由导致白屏。因此需要从新访问一次路由才行。 具体原因见文章链接：https://blog.csdn.net/qq_41912398/article/details/109231418退出后，新的用户会访问到原本不属于该用户的路由，必须刷新页面才会正常。后来通过排查，导致这个问题的原因是因为当用户登出时，当前路由router对象还保留着上一个用户的动态路由，因次，还需要在登出的时候，对路由router对象进行初始化。我们在src/router目录下的 index.js文件里写一个初始化方法：//初始化路由 export function resetRouter() { //获取当前路有对象 let routers = router.getRoutes() //剔除动态路由 routers.map((it) => { if (!['login', '403', ':pathMatch(.*)*'].includes(it.name)) { router.removeRoute(it.name) } }) }然后在登出的地方引用这个方法并执行import { resetRouter } from '@/router/index.js' async logout() { resetRouter() await store.dispatch('user/Logout') this.$router.push('/login') },还有一个关于vuex的问题，从之前的代码里不难看出，我们将用户信息，角色、权限、动态路由等都存在了vuex中。 由于vuex的特性，用户会在刷新页面后，vuex里的数据会被清空，这个时候就会导致页面直接跳转到登录页 。这肯定是不能接受的，因此，我们需要对vuex里的数据在刷新的时候做持久化处理。逻辑也很简单，监听页面刷新事件，beforeunload这个方法恰好就能做到，在页面刷新的时候，将vuex中的数据缓存到浏览器本地临时缓存中，然后再在页面初始化的时候，从本地临时缓存中取出存入vuex对象中。 附上代码：（项目根目录下的的App.vue文件中,这里是vue2的写法)created() { //在页面加载时读取sessionStorage里的状态信息 sessionStorage.getItem('userMsg') && this.$store.replaceState( Object.assign( this.$store.state, JSON.parse(sessionStorage.getItem('userMsg')) ) ) //在页面刷新时将vuex里的信息保存到sessionStorage里 window.addEventListener('beforeunload', () => { sessionStorage.setItem('userMsg', JSON.stringify(this.$store.state)) }) },这里再附上src/router目录下的 index.js代码，方便大家理解路由这块：import { createWebHistory, createRouter, useRouter } from 'vue-router' import { constantRouterMap } from '@/config/router.config.js' import { generator } from '@/router/generatorRouters' const router = createRouter({ history: createWebHistory(), routes: constantRouterMap, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } else { return { top: 0 } } }, }) //这里是我为了刷新的时候不调取获取路由信息的接口，做的本地缓存，因为刷新一次就要获取一遍用户信息和 //动态路由表信息太影响用户体验了，尤其是在网速差的情况下。没这方面需求的可以不用管 let asyncRouterMap = [] function handleLocalRouter() { if (sessionStorage.getItem('generateRoutes')) { asyncRouterMap = asyncRouterMap.concat( JSON.parse(sessionStorage.getItem('generateRoutes')) ) const generatedRoutes = generator(asyncRouterMap) generatedRoutes.push({ path: '/:pathMatch(.*)*', redirect: '/404', hidden: true, }) generatedRoutes.forEach((route) => { router.addRoute(route) }) } } //初始化路由 export function resetRouter() { let routers = router.getRoutes() routers.map((it) => { if (!['login', '403', ':pathMatch(.*)*'].includes(it.name)) { router.removeRoute(it.name) } }) } handleLocalRouter() export default router6.结束与优化好，至此，整个RBAC的前端权限方案设计到实现就已经宣告完成，其实还有很多需要优化的地方，比如把用户路由表信息缓存到本地临时缓存中，这样用户每次刷新页面的时候就不会因为vuex的特性需要再去请求一边接口。（但是老实说，我觉得这个优化方案还是不够好，因为将路由表存在本地缓存中就已经背离了安全性这一块，但是在网络较差的情况下，刷新就要调取角色信息接口，调取获取私有路由的接口，再到构建新的完整路由这个过程期间，页面会处于白屏状态，非常影响用户体验。因此，只能取此下策。到底要不要缓存，看个人的想法吧。） 还比如 await store.dispatch('user/GetInfo') await store.dispatch('async-router/GenerateRoutes') let accessRoutes = store.getters['async-router/addRouters'] accessRoutes.forEach((route) => { //用来判断是否是外链 if (!isHttp(route.path)) { router.addRoute(route) // 动态添加可访问路由表 } })这段获取用户信息->构建路由->添加动态路由的代码可以抽离出来，单独封装。还可以根据当前环境是否是 development来判断是否是开发环境，从而准备另外一套前端开发场景下使用的完整路由表，这样，就可以在不依赖后端的情况下，进行前端开发。另外还有侧边导航栏的处理，这个我没讲，一方面是相对简单，我们获取到格式化之后的动态路由数据的同时，其实也就是获取到了导航栏的菜单信息。 另一方面，每个人的项目页面布局方式不一样，导航栏的设计也会不一样。在此基础上，针对性的对数据进行修饰和改造即可。
迭代器和生成器JavaScript中的迭代器和生成器都是ES6中引入的重要特性，迭代器和生成器都实现了迭代器协议，它们具有相似的使用方法和不同的用途和使用场景。前提、迭代协议迭代协议规定了一个对象必须实现 next()方法，该方法返回一个对象，该对象包含两个属性：value：表示下一个要返回的元素的值。done：一个布尔值，如果迭代器已经遍历完集合中的所有元素，则为 true；否则为 false。迭代器创建迭代器在JavaScript中，你可以通过以下几种方式创建迭代器：默认迭代器：某些内置对象（如数组、字符串、Map和Set等）具有默认的迭代器，可以通过调用它们的Symbol.iterator方法获取。 const arr = [1, 2, 3]; const iterator = arrSymbol.iterator; console.log(iterator.next()); // { value: 1, done: false } console.log(iterator.next()); // { value: 2, done: false } console.log(iterator.next()); // { value: 3, done: false }console.log(iterator.next()); // { value: undefined, done: true }可迭代对象：任何实现了Symbol.iterator方法的对象都是可迭代对象。你可以通过定义一个带有Symbol.iterator方法的对象来创建自定义的可迭代对象。 const myIterable = { data: [1, 2, 3], [Symbol.iterator]() { let index = 0; return { next: () => { if (index < this.data.length) { return { value: this.data[index++], done: false }; } else { return { value: undefined, done: true }; } } }; } }; const iterator = myIterable[Symbol.iterator](); console.log(iterator.next()); // { value: 1, done: false } // ...生成器函数：生成器函数（使用function*定义的函数）返回的是一个迭代器对象，该对象也实现了迭代器协议。 function* myGenerator() { yield 1; yield 2; yield 3; } const iterator = myGenerator(); console.log(iterator.next()); // { value: 1, done: false } // ...使用迭代器迭代器通常与for...of循环一起使用，因为for...of循环会自动调用迭代器的next()方法，直到done属性为true。 const arr = [1, 2, 3]; for (const value of arr) { console.log(value); // 1, 2, 3 }在这个例子中，arr是一个数组，它有一个默认的迭代器。for...of循环会调用这个迭代器，并逐个处理数组中的元素。总之，迭代器是JavaScript中一个强大的工具，它允许你以灵活和高效的方式处理集合中的元素。通过实现迭代器协议，你可以创建自己的可迭代对象，并使用for...of循环或其他迭代方法来遍历它们。生成器在JavaScript中，生成器（Generators）是一种可以暂停和恢复执行的函数。它们允许你使用function*语法定义一个生成器函数，并使用yield关键字在函数执行过程中暂停和恢复。生成器特别适用于需要逐步生成值或处理异步操作的场景。下面是一个简单的例子，展示了如何定义和使用生成器：定义生成器函数 function* numberGenerator() { console.log('Start'); yield 1; console.log('Resume at 2'); yield 2; console.log('Resume at 3'); yield 3; console.log('End'); }使用生成器函数生成器函数返回一个生成器对象，这个对象有一个next()方法，用于控制生成器的执行。每次调用next()方法，生成器函数会执行到下一个yield语句，并返回该语句的值。 const gen = numberGenerator(); console.log(gen.next().value); // 输出: 1, 控制台输出: Start console.log(gen.next().value); // 输出: 2, 控制台输出: Resume at 2 console.log(gen.next().value); // 输出: 3, 控制台输出: Resume at 3 console.log(gen.next().done); // 输出: true, 控制台输出: End迭代生成器你也可以使用for...of循环来迭代生成器，这样更简洁： for (const value of numberGenerator()) { console.log(value); // 插入一些异步操作或其他逻辑 }处理异步操作生成器在处理异步操作时特别有用，通常与Promise和co库（或其他类似的库）一起使用。下面是一个简单的例子，展示了如何使用生成器处理异步操作： function fetchData(url) { return new Promise((resolve) => { setTimeout(() => { resolve(`Data from ${url}`); }, 1000); }); } function* asyncGenerator() { const data1 = yield fetchData('http://example.com/1'); console.log(data1); const data2 = yield fetchData('http://example.com/2'); console.log(data2); } // 使用一个辅助函数来处理生成器的异步执行（这里我们手动实现一个简单的版本） function runGenerator(gen) { const it = gen(); function next(value) { const result = it.next(value); if (!result.done) { result.value.then(next); } } next(); } runGenerator(asyncGenerator);在这个例子中，生成器asyncGenerator在每次yield时都会等待一个Promise解决，然后继续执行。runGenerator函数负责处理这种异步流程。需要注意的是，在现代JavaScript开发中，async/await语法已经变得非常流行，并且通常被用作处理异步操作的首选方法。生成器在处理某些特定场景时仍然很有用，但对于大多数异步编程任务，async/await通常更为简洁和直观。迭代器和生成器对比一、定义与功能迭代器 ：迭代器是一个对象，它实现了迭代器协议，允许你按顺序逐个访问集合中的元素。迭代器对象具有一个 next()方法，该方法每次调用时都会返回集合中的下一个元素，直到遍历完所有元素。迭代器主要用于遍历可迭代对象（如数组、字符串、Map、Set等），提供了一种统一的遍历机制。生成器 ：生成器是一种特殊的函数，它能够在执行过程中暂停并恢复执行。生成器函数使用 function*语法定义，并通过 yield关键字来产生值。生成器函数返回的是一个迭代器对象，该对象也实现了迭代器协议，因此可以使用 next()方法来控制生成器的执行。生成器的主要优势在于能够按需生成一系列值，而不是一次性返回所有值，这对于处理大量数据或异步操作非常有用。二、使用场景与特点迭代器 ：适用于简单遍历可迭代对象的场景。迭代器提供了一种标准化的遍历机制，使得不同数据结构可以使用相同的语法进行遍历。迭代器在遍历过程中不会占用过多内存，因为它按需访问元素。生成器 ：适用于需要按需生成值的场景，如大数据处理、异步编程等。生成器可以暂停和恢复执行，这使得它能够在等待异步操作完成时释放控制权，从而提高程序的响应性和性能。生成器还可以用于创建无限序列或惰性求值的数据结构。三、性能与应用迭代器 ：由于迭代器只是按需访问元素，因此在处理大量数据时具有较高的性能。迭代器是JavaScript中遍历可迭代对象的标准方式，因此广泛应用于各种场景。生成器 ：生成器在处理复杂数据或异步操作时具有显著优势，因为它能够暂停和恢复执行，从而避免阻塞主线程。生成器还可以与其他异步处理机制（如Promises、async/await等）结合使用，以实现更高效的异步编程。总结：JavaScript中的迭代器和生成器虽然都实现了迭代器协议，但它们在使用场景、功能特点和性能方面还是有区别的。迭代器主要用于简单遍历可迭代对象，而生成器则适用于需要按需生成值的复杂场景。在选择使用迭代器还是生成器时，应根据具体的应用需求和性能考虑来决定。
前言关于前端的性能及体验问题，图片的加载绝对是重中之重，尤其是在一些有着许多大图的页面，在网络不稳定或稍慢的时候，我们经常能够看到页面中图片的位置是一片空白，等图片加载完成才开始慢慢渲染，在这段时间对用户的体验其实是非常不友好的。那么我们应该如何来进行优化呢？大家想说的是不是：把图片压缩一下不就好了？是的，图片压缩是我们前端开发过程必备的一步，但是光靠图片压缩并不能解决所有的图片体验问题，有些图片本身就比较大，内容质量都比较高，此时压缩图片带来的加载性能提升似乎就没有那么明显了，这时候我们就可能需要使用一些技巧来进行优化了。background-image之前在需求中有使用过 background-image来进行优化，先来看看效果：优化前：优化后：从上述对比中可以清晰看出，优化后的图片加载体验相较于优化前实现了显著提升。优化后，用户所经历的白屏时间显著缩短，取而代之的是图片从模糊逐渐变得清晰的过程。这意味着用户能够更早地预览到图片的内容，而无需再焦急地等待白屏消失，从而大大提升了用户的体验感受！原理原理实际上很简单，从上图我们可以看到优化后的效果实际上是加载了两张图片，一张小图（1.9k），一张原图（2.8M），所有最开始看到的模糊图片其实是这张小图，最后变清晰看到的是原图。这里使用的技术也很简单，只需要CSS的 background-image❝background-image 属性用于为一个元素设置一个或者多个背景图像。.pic_container { width: 300px; height: 375px; background-image: url("../assets/origin.jpg"), url("../assets/small.jpg"); background-repeat: no-repeat; background-size: 100% 100%; }在绘制时，图像以 z 方向堆叠的方式进行。先指定的图像会在之后指定的图像上面绘制。因此指定的第一个图像“最接近用户”。也就是说，原图与缩略图是叠在一起的，并且原图在上缩略图在下，在原图还没加载完成时，可以先看到下面的缩略图，也就是那张模糊的图片，等原图加载完成之后，浏览器才会开始渲染原图，由于两张图是在同一个位置，所以我们可以看到一个从模糊变清晰的过程。img + background-image虽然 background-image天生就支持设置多个背景图，但更多时候我们还是使用 img来承载图片，但是 img的 src属性可没有这一特点。是的，如果不使用JS的话光靠 img也是做不到上面的效果的。如果想要使用 img的话，可以使用 img + background-image组合来实现。 .pic_container { background-image: url("../assets/small.jpg"); background-repeat: no-repeat; background-size: 100% 100%; img { width: 300px; height: 375px; } } 效果跟上面那种类似，实际上原理也差不多。需要注意的是：img元素占位在图片还没开始渲染时，它在页面上实际上是透明的，所以我们能够在看到下面的背景图片，等 img资源加载完成开始渲染时，才会慢慢遮盖下面的背景图。为了更方便的复用，我们可以把它封装成一个通用组件用来提升用户体验，但该组件需要提供两张图片，一张原图，一张缩略图。这样做有的同学可能觉得很麻烦，那么有没有更简便的方法呢？渐进式图片除了以上这两种方案，我还见过另外一种方案实现的效果，但它并不依赖于 background-image，只需要 img元素就可以，比如：这种效果应该图片格式的功劳，通常网站使用的 JPEG 的内容显示通常有两种类型：基线 JPEG渐进式 JPEG一般来说我们见的比较多的应该是基线JPEG基线JPEG（Baseline JPEG）基线 JPEG 最常见的应用之一是在 Web 浏览器中呈现的图像。基线 JPEG 算法在从网络下载处理数据时逐行渲染图像。当数据从网络到达计算机的缓冲区时，数据以流的形式进行处理。基线式的编码方式是图片从上到下，从左到右地进行处理和编码。这就会形成我们查看大图时比较常见的从上至下逐行显示，即首先显示图像的顶部。然后它会逐行加载到底部，直到显示出完整的图像。这种格式的图片在面对越大的图片时，往往需要我们等待越长的时间才能看到完整图片，比较容易让网站流失用户渐进式 JPEG（Progressive JPEG）渐进式 JPEG 以特定方式压缩照片和图形，与基线 JPEG 不同，PJPEG 在 Web 浏览器中呈现时，会首先给出模糊图像的外观。然后一点一点地开始图片渲染，直到它显示完全渲染的图像。浏览器实际上是逐行解释图像，但在占位符中提供了完整图像的模糊预览。随着 Web 浏览器的渲染引擎处理数据，图像的对比度开始变得更清晰、更详细。直到最后渲染完毕，用户将看到完整的清晰图像。如何生成渐进式图片通常情况下，设计师给我们提供的切图素材通常就是普通格式的图片，并不支持渐进式加载，但实际上他们在导出图片的时候可以选择导出为渐进式图片图片的，但他们可能并不会帮你这样干。那么我们自己能不能将非渐进式图片转换为渐进式图片呢，答案是可以的！GraphicsMagick我们可以使用该工具库来生成渐进式图片，我们只需要安装node对应的版本npm install gmconst gm = require('gm').subClass({ imageMagick: true }); const path = require('path'); function transformImage(imagePath, transform, generatePath, callback) { gm(imagePath) .interlace('Line') // 生成渐进式图片 .resize(transform.width, transform.height) .write(generatePath, callback); } const basePath = path.join(__dirname, '../src/assets/'); transformImage( path.join(basePath, 'sy.pic.jpg'), { width: 500, height: 500 }, path.join(basePath, 'sy.line.jpeg'), (res) => { console.log(res); } );如何选择首先明确的一点，渐进式图片的加载体验的确是要比普通图片的加载要好上不少，无论是我们自己模拟的渐进式加载还是通过依赖图片本身算法来实现的，这几种方案在各大网站都能看到在应用。渐进式图片的优缺点优点 ：移动网络流量优化 ：渐进式图片下载技术允许用户仅初始下载图片的一部分，有效降低图像分辨率并减少数据使用量，特别适用于移动网络环境。减少加载等待时间 ：渐进式图片能够让用户先快速预览图片的轮廓，随后逐步加载更多图片细节，提升用户体验。缺点 ：图片格式转换成本 ：由于大多数现有图片采用普通压缩格式，转换为渐进式格式需要额外的处理成本。兼容性 ：部分老旧浏览器（例如IE8）对渐进式图片格式的支持不足，尽管随着时间推移，这些浏览器将逐渐被市场淘汰，但当前仍可能影响部分用户的浏览体验。
JavaScript逆向工程（ReverseEngineering）是指分析和理解已编译或已部署的JavaScript代码的过程，以便了解其工作原理、找到漏洞、进行修改或复制其功能。这个过程通常用于安全研究、调试、性能优化或破解某些软件。注意： 逆向工程在某些情况下可能涉及法律问题，尤其是当用于破解版权保护的软件时。确保你的行为符合当地法律和行业准则。以下是一些JavaScript逆向工程的基本步骤和技巧：1. 收集信息确定目标 ：确定你要逆向的JavaScript代码的位置。这可以是网页中的 03、事件监听操作事件监听也是JS逆向油猴脚本中常用的一种Hook方式。通过监听网页上的事件，我们可以触发自定义的操作和行为。 04、AJAX拦截操作AJAX拦截也是JS逆向油猴脚本中常用的一种Hook方式。通过拦截网页上的AJAX请求，我们可以实现对数据的控制和修改。 05、函数替换操作函数替换也是JS逆向油猴脚本中常用的一种Hook方式。通过替换网页上的函数，我们可以实现对函数的控制和修改。 06、Header操作Header Hook 用于定位 Header 中关键参数生成位置，以下代码演示了当 Header 中包含 Authorization 关键字时，则插入断点： 07、URL操作URL Hook 用于定位请求 URL 中关键参数生成位置，以下代码演示了当请求的 URL 里包含 login 关键字时，则插入断点： 08、JSON.stringify操作JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串，在某些站点的加密过程中可能会遇到，以下代码演示了遇到 JSON.stringify() 时，则插入断点： 09、JSON.parse操作JSON.parse() 方法用于将一个 JSON 字符串转换为对象，在某些站点的加密过程中可能会遇到，以下代码演示了遇到 JSON.parse() 时，则插入断点：(function() { var parse = JSON.parse; JSON.parse = function(params) { console.log("Hook JSON.parse ——> ", params); debugger; return parse(params); } })();10、eval操作JavaScript eval() 函数的作用是计算 JavaScript 字符串，并把它作为 脚本代码来执行。如果参数是一个表达式，eval() 函数将执行表达式。如果参数是 Javascript 语句，eval() 将执行 Javascript 语句，经常被用来动态执行 JS。以下代码执行后，之后所有的 eval() 操作都会在控制台打印输出将要执行的 JS 源码：(function() { // 保存原始方法 window.__cr_eval = window.eval; // 重写 eval var myeval = function(src) { console.log(src); console.log("=============== eval end ==============="); debugger; return window.__cr_eval(src); } // 屏蔽 JS 中对原生函数 native 属性的检测 var _myeval = myeval.bind(null); _myeval.toString = window.__cr_eval.toString; Object.defineProperty(window, 'eval', { value: _myeval }); })();11、Function操作以下代码执行后，所有的函数操作都会在控制台打印输出将要执行的 JS 源码：(function() { // 保存原始方法 window.__cr_fun = window.Function; // 重写 function var myfun = function() { var args = Array.prototype.slice.call(arguments, 0, -1).join(","), src = arguments[arguments.length - 1]; console.log(src); console.log("=============== Function end ==============="); debugger; return window.__cr_fun.apply(this, arguments); } // 屏蔽js中对原生函数native属性的检测 myfun.toString = function() { return window.__cr_fun + "" } Object.defineProperty(window, 'Function', { value: myfun }); })();
青风 阿里云开发者;) 2025年01月15日 08:31浙江这篇文章分析了Go编译时插桩工具导致go build -race竞态检测产生崩溃的原因。不久前， 阿里云 ARMS 团队、编译器团队、MSE 团队携手合作，共同发布并开源了 Go 语言的编译时自动插桩技术。 该技术以其零侵入的特性，为 Go 应用提供了与 Java 监控能力相媲美的解决方案。开发者只需将 go build 替换为新编译命令 otel go build，就能实现对 Go 应用的全面监控和治理。问题描述近期，我们收到用户反馈，使用otel go build -race替代正常的go build -race命令后，编译生成的程序会导致崩溃。-race[3]是Go编译器的一个参数，用于检测数据竞争（data race）问题。通过为每个变量的访问添加额外检查，确保多个 goroutine 不会以不安全方式同时访问这些变量。理论上，我们的工具不应影响-race竞态检查的代码，因此出现崩溃的现象是非预期的，所以我们花了一些时间排查这个崩溃问题，崩溃的堆栈信息如下：(gdb) bt #0 0x000000000041e1c0 in __tsan_func_enter () #1 0x00000000004ad05a in racecall () #2 0x0000000000000001 in ?? () #3 0x00000000004acf99 in racefuncenter () #4 0x00000000004ae7f1 in runtime.racefuncenter (callpc=4317632) #5 0x0000000000a247d8 in ../sdk/trace.(*traceContext).TakeSnapShot (tc=<optimized out>, ~r0=...) #6 0x00000000004a2c25 in runtime.contextPropagate #7 0x0000000000480185 in runtime.newproc1.func1 () #8 0x00000000004800e2 in runtime.newproc1 (fn=0xc00030a1f0, callergp=0xc0000061e0, callerpc=12379404, retVal0=0xc0002c8f00) #9 0x000000000047fc3f in runtime.newproc.func1 () #10 0x00000000004a992a in runtime.systemstack () ....可以看到崩溃源于 __tsan_func_enter，而引发该问题的关键点是 runtime.contextPropagate。我们的工具在 runtime.newproc1 函数的开头插入了以下代码： func newproc1(fn * funcval, callergp * g, callerpc uintptr)(retVal0 * g) { // 我们插入的代码 retVal0.otel_trace_context = contextPropagate(callergp.otel_trace_context) ... } // 我们插入的代码 func contextPropagate(tls interface{}) interface{} { if tls == nil { return nil } if taker, ok := tls.(ContextSnapshoter); ok { return taker.TakeSnapShot() } return tls } // 我们插入的代码 func(tc * traceContext) TakeSnapShot() interface{ } { ... }TakeSnapShot 被 Go 编译器在函数入口和出口分别注入了 racefuncenter() 和 racefuncexit()，最终调用 __tsan_func_enter导致崩溃。由此确定崩溃问题确实是我们的注入代码导致的，继续深入排查。排查过程使用 objdump 查看 __tsan_func_enter 的源码，看到它接收两个函数参数，出错的地方是第一行 mov 0x10(%rdi),%rdx，它约等于 rdx = *(rdi + 0x10)。打印寄存器后发现 rdi = 0，根据调用约定，rdi 存放的是第一个函数参数，因此这里的问题就是函数第一个参数 thr 为 0。// void __tsan_func_enter(ThreadState *thr, void *pc); 000000000041e1c0 <__tsan_func_enter>: 41e1c0: 48 8b 57 10 mov 0x10(%rdi),%rdx 41e1c4: 48 8d 42 08 lea 0x8(%rdx),%rax 41e1c8: a9 f0 0f 00 00 test $0xff0,%eax ...那么第一个参数 thr 是谁传进来的呢？接着往上分析调用链。调用链分析出错的整个调用链是 racefuncenter(Go) -> racecall(Go) -> __tsan_func_enter(C)。需要注意的是，前两个函数都是 Go 代码，Go 函数调用 Go 函数遵循 Go 的调用约定。在 amd64 平台，前九个函数参数使用以下寄存器：另外以下寄存器用于特殊用途：后两个函数一个Go代码一个C代码，Go 调用 C 的情况下，遵循 System V AMD64 调用约定，在 Linux 平台上使用以下寄存器作为前六个参数：理解了Go和C的调用约定之后，再来看整个调用链的代码：TEXT racefuncenter<>(SB), NOSPLIT|NOFRAME, $0-0 MOVQ DX, BXx MOVQ g_racectx(R14), RARG0 // RSI存放thr MOVQ R11, RARG1 // RDI存放pc MOVQ $__tsan_func_enter(SB), AX // AX存放__tsan_func_enter函数指针 CALL racecall<>(SB) MOVQ BX, DX RETTEXT racecall<>(SB), NOSPLIT|NOFRAME, $0-0 ... CALL AX // 调用__tsan_func_enter函数指针 ...racefuncenter 将 g_racectx(R14) 和 R11 分别放入 C 调用约定的参数寄存器 RSI(RARG0) 和 RDI(RARG1)，并将 __tsan_func_enter 放入 Go 调用约定的参数寄存器 RAX，然后调用 racecall，它进一步调用 __tsan_func_enter(RAX)，这一系列操作大致相当于 __tsan_func_enter(g_racectx(R14), R11)。不难看出，问题的根源在于 g_racectx(R14) 为 0。根据 Go 的调用约定R14 存放当前 goroutine ，它不可能为 0 ，因此出问题的必然是R14.racectx 字段为 0。为了避免无效努力，通过调试器dlv二次确认：(dlv) p *(*runtime.g)(R14) runtime.g { racectx: 0, ... }那么为什么当前R14.racectx为0？下一步看看R14具体的状态。协程调度func newproc(fn * funcval) { gp:= getg() pc:= sys.GetCallerPC() #1 systemstack(func() { newg:= newproc1(fn, gp, pc, false, waitReasonZero) #2 ... }) }经过排查，在代码 #1 处，R14.racectx 是正常的，但到了代码 #2 处，R14.racectx 就为空了，原因是 systemstack 被调用，它有一个切换协程的动作，具体如下：// func systemstack(fn func()) TEXT runtime·systemstack(SB), NOSPLIT, $0 - 8 ... // 切换到g0协程 MOVQ DX, g(CX) MOVQ DX, R14 // 设置 R14 寄存器 MOVQ(g_sched + gobuf_sp)(DX), SP // 在g0协程上运行目标函数fn MOVQ DI, DX MOVQ 0(DI), DI CALL DI // 切换回原始协程 // ...原来systemstack有一个切换协程的动作，会先把当前协程切换成g0，然后执行fn，最后恢复原始协程执行。在 Go 语言的 GMP（Goroutine-Machine-Processor）调度模型中，每个系统级线程 M 都拥有一个特殊的g0 协程，以及若干用于执行用户任务的普通协程 g。g0 协程主要负责当前 M 上用户 g 的调度工作。由于协程调度是不可抢占的，调度过程中会临时切换到系统栈（system stack）上执行代码。在系统栈上运行的代码是隐式不可抢占的，并且垃圾回收器不会扫描系统栈。到这里我们已经知道执行 newproc1 时的协程总是 g0，而 g0.racectx是在 main 执行开始时被主动设置为 0，最终导致程序崩溃：// src/runtime/proc.go#main // The main goroutine. func main() { mp := getg().m // g0 的 racectx 仅用于作为主 goroutine 的父级。 // 不应将其用作其他目的。 mp.g0.racectx = 0 ...解决方案到这里基本上可以做一个总结了，程序崩溃的原因如下：newproc1 中插入的 contextPropagate 调用TakeSnapshot，而TakeSnapshot被 go build -race 强行在函数开始插入了 racefuncenter() 函数调用，该函数将使用 racectx。newproc1 是在 g0 协程执行下运行，该协程的 racectx 字段是 0，最终导致崩溃。一个解决办法是给TakeSnapshot加上 Go编译器的特殊指令 //go:norace，该指令需紧跟在函数声明后面，用于指定该函数的内存访问将被竞态检测器忽略，Go编译器将不会强行插入racefuncenter()调用。疑惑1runtime.newproc1 中不只调用了我们注入的contextPropagate，还有其他函数调用，为什么这些函数没有被编译器插入 race 检查的代码（如 racefuncenter）？经过排查后发现，Go 编译器会特殊处理 runtime 包，针对 runtime 包中的代码设置 NoInstrument 标志，从而跳过生成 race 检查的代码：// /src/cmd/internal/objabi/pkgspecial.go var pkgSpecialsOnce = sync.OnceValue(func() map[string]PkgSpecial { ... for _, pkg := range runtimePkgs { set(pkg, func(ps *PkgSpecial) { ps.Runtime = true ps.NoInstrument = true }) } ... })疑惑2理论上插入 //go:norace 之后问题应该得到解决，但实际上程序还是发生了崩溃。经过排查发现，TakeSnapShot 中有 map 初始化和 map 循环操作，这些操作会被编译器展开成 mapinititer() 等函数调用。这些函数直接手动启用了竞态检测器，而且无法加上 //go:norace：func mapiterinit(t *abi.SwissMapType, m *maps.Map, it *maps.Iter) { if raceenabled && m != nil { // 主动的race检查 callerpc := sys.GetCallerPC() racereadpc(unsafe.Pointer(m), callerpc, abi.FuncPCABIInternal(mapiterinit)) } ... }对此问题的解决办法是在newproc1注入的代码里面，避免使用map数据结构。总结以上就是 Go 自动插桩工具在使用 go build -race 时出现崩溃的分析全过程。通过对崩溃内容和调用链的排查，我们找到了产生问题的根本原因以及相应的解决方案。这将有助于我们在理解运行时机制的基础上，更加谨慎地编写注入到运行时的代码。最后诚邀大家试用我们的 Go自动插桩商业化产品 [2]，并加入我们的钉钉群（开源群：102565007776，商业化群：35568145），共同提升Go应用监控与服务治理能力。通过群策群力，我们相信能为Go开发者社区带来更加优质的云原生体验。[1] Go自动插桩开源项目：https://github.com/alibaba/opentelemetry-go-auto-instrumentation[2] 阿里云ARMS Go Agent商业版：https://help.aliyun.com/zh/arms/tracing-analysis/monitor-go-applications/[3] Go竞态检查 https://go.dev/doc/articles/race_detector无代理ECS数据备份与高效环境搭建基于快照提供数据保护和环境搭建，实现无代理且有效可靠的数据备份，同时可以快速克隆部署开发测试环境。点击阅读原文查看详情。[https://www.aliyun.com/solution/tech-solution/ecs-backup]()
开发注意事项考虑 边界值 ：如果要展示一个列表，就要考虑列表为空、列表长度超过一页的情况；如果展示的是文字，则要考虑文字为空、文字超长的情况；访问a.b.c时，a或b是否可能为undefined。考虑 特殊场景 ：如交互状态（hover、disabled、文字提示）、浮点数计算精度（使用utils方法）、防重复提交、分辨率兼容、移动设备兼容、事件冒泡、防抖和节流；考虑 需求变更和功能拓展 ：需求变更是不可避免的，那就要在开发的时候考虑到哪些地方容易变(数值、变量)，哪些不容易变（框架、模式），提前做好设计规划，减少因需求变更造成的大规模重构。考虑代码 可读性 ：复杂方法标注用途、复杂逻辑解释清楚、修改他人代码先理解上下文并做好自测。保持优化代码的好习惯：所有不合理的问题，都是可以改的，代价大，就细致谋划，不要搁置，避免埋雷。代码越短就越好吗？实际的业务书写中，如果减少10%的代码换来的是可读性变差，那并不推荐这么做。我们推荐的是，在保证可读性的前提下，尽可能让代码变少。 // bad 三元嵌套三元不利于可读性 let message = (age < 3) ? 'Hi, baby!' : (age < 80) ? 'Hello!' : 'What an unusual age!'; // good if (age < 3) { message = 'Hi, baby!'; } else if (age < 80) { message = 'Hello!'; } else { message = 'What an unusual age!'; } // bad if (company == 'Netscape') { return true; } else { return false; } // good return company === 'Netscape';魔法值的问题魔法值，也叫做魔法数值、魔法数字，通常是指在代码编写时莫名出现的数字， 无法直接判断数值代表的含义，必须通过联系代码上下文分析才可以明白， 严重降低了代码的可读性。除数字之外，代码中作为key值的常量字符串也被认为是魔法值， 尽管其表示含义比数值较为清晰，但是仍然会产生不规范问题。 // 魔法值举例 if(flag === '5'){ ....... } if (businessType === 101){ ....... }通常会采用枚举类型来解决魔法值问题，由于JS中没有枚举类型，可以使用对象字面量的方式来模拟枚举，如下： const BusinessTypeEnum = { SYSTEM: 0, // 系统 CRM: 1, // CRM JXC: 2, // JXC UNKNOWN: 404, // 未知对象类型 CUSTOMER_MANAGEMENT: 100, // 客户管理 CUSTOMER: 101, // 客户 CUSTOMER_FOCUS: 102, // 重点客户 CUSTOMER_DEAL: 103, // 成交客户 CUSTOMER_FOLLOW: 104, // 跟进客户 CUSTOMER_PUBLIC: 105 // 客户公海池 } if (businessType === BusinessTypeEnum.CUSTOMER){ ....... }采用枚举的另外一个好处，当某个值因为需求迭代需求变更时，我们只需要在枚举中将该值替换，并不需要在全局搜素替换。程序中的电车难题如图，当一辆电车快速驶来，无论图上的人采取哪种选择，似乎都是错误的。但是，在程序中出现预期之外的错误必须要导致一个负面结果时，一定要选择代价最小的。举个例子： 某同学开发了一个新手引导弹窗功能，该功能有个透明的全屏遮罩，只有当新手引导结束时，遮罩才会消失。由于程序中存在一处逻辑判断不严谨，导致在特定情况下全屏遮罩不能被关闭，然后整个系统都不能被点击了。一个简单的新手引导功能却引发了系统不可用的严重故障，这是非常大的代价。通过上述例子去分析，系统不可用的负面影响远大于新手引导不可用。因此，我们应该修改逻辑判断方案，当程序脱离预期执行时，直接去关闭新手引导。 // before if (step === 0) { dialog.close() } else { dialog.show() } // after if ([1, 2, 3].includes(step)) { dialog.show() } else { dialog.close() }我们无法保证自己写的代码没有任何差错，但是我们可以提前考虑，万一发生预期之外的错误，我们要如何处理来让损失最低。不要让用户等待由用户触发的交互操作，应立即给予响应。立即响应不等于立即呈现数据，有些异步操作，无法立即呈现数据给用户，但应马上给予loading提示，告知用户数据正在处理中。下面的代码中，用户点击编辑按钮时，会先发起一个异步请求，拿到数据结果后再显示弹窗。这个短暂且没有任何提示的等待，会让用户认为是程序卡顿，体验不畅，间接诱导用户重复点击按钮。正确的做法应该是先展示弹窗，再去请求数据。三元和if该怎么选？ // 如果条件表达式后面跟的是返回值，建议使用三元运算，如下方的例子，就可以优先考虑使用三元运算 // good a > b ? 1 : 2 // bad if (a > b) { return 1 } else { return 2 } // 其他复杂的分支代码，写成三元可能并不利于阅读，建议使用if语句，如下面的例子 // good if (a > b && b !== 0) { val = a + b } else { val = a - b } // bad a > b && b !== 0 ? val = a + b : val = a - bwhile和for该怎么选？for循环往往是用来遍历一个固定长度的可迭代对象，遍历的次数一定是<=可迭代对象的长度，如数组遍历、对象遍历等等；for和while在绝大部分场景下都可以互换，只是部分场景下，使用while会更符合我们的直觉。while循环往往用于 不定次数的条件执行 ，即达到目标条件就终止，但我们事先并不知道达到这个目标条件需要进行多少次执行。举个例子，我们希望从1开始，取前10个能整除3或5的数字，参考如下代码： let numArr = [] let i = 1 while (numArr.length < 10) { if (i % 3 === 0 || i % 5 === ) { numArr.push(i) } i++ }while在某些场景下，可以代替递归函数。比如我们希望得到100以内的斐波那契数列： // 使用递归 function fib (prev = 0, next = 1) { if (next < 100) { return [next, ...fib(next, prev + next)] } return [] } // 使用while functon fib2 (n = 100) { let prev = 0 let next = 1 let result = [] while (next < n) { result.push(next) const temp = prev prev = next next = next + temp } return result }上面的例子很难用for循环去实现，因为我们并不知道100以内存在多少个斐波那契数字，但知道终止条件是数字必须小于100，因此更适合用while循环来实现。再来说说递归和while，递归往往在代码量上来看是简洁的，但本质上它是一个N层的函数嵌套，所以直觉上不易理解，且耗性能（不考虑尾递归优化的前提下）；while循环只有一层嵌套，所以直觉上更容易理解，且性能更优。这里并不是说递归不好，不推荐大家使用，一来性能并不是大家首先要考虑的问题，二来while只能在部分场景中来代替递归。
【CSS】font-weight设置为500显示不出加粗效果问题出在操作系统上：macOS 系统默认的华文黑体(STHeiti) 有七个矢量级别：Heavy/Bold/MediumP4/Regular/Thin/Light/UltraLightP2 ，它包含上面 CSS 中设定的 500 这个精度。Windows 系统默认的宋体(simsun) 没有那么多级别。在缺少级别支持的前提下，CSS 会根据回退机制寻找一个类似的精度，结果就变成了400 (Normal)。回退机制：如果指定的权重值不可用，则使用以下规则来确定实际呈现的权重：如果指定的权重值在 400和 500之间（包括 400和 500）：按升序查找指定值与 500之间的可用权重；如果未找到匹配项，按降序查找小于指定值的可用权重；如果未找到匹配项，按升序查找大于 500的可用权重。如果指定值小于 400，按降序查找小于指定值的可用权重。如果未找到匹配项，按升序查找大于指定值的可用权重（先尽可能的小，再尽可能的大）。如果指定值大于 500，按升序查找大于指定值的可用权重。如果未找到匹配项，按降序查找小于指定值的可用权重（先尽可能的大，再尽可能的小）。以上策略意味着，如果一个字体只有 normal 和 bold 两种粗细值选择，指定粗细值为 100-500 时，实际渲染时将使用 normal，指定粗细值为 501-900 时，实际渲染时将使用 bold 。