微信jsapi签名报错config:invalid url domain和config:invalid signature问题
背景记录下最近遇到了jsapi签名的报错：config:invalid url domainconfig:invalid signature排查步骤1. 先根据官方文档的步骤先排查一遍这是常规的解决办法，大部分的报错都是这些原因导致的，请确认没问题再往下排查参考：企业微信：https://developer.work.weixin.qq.com/document/path/90509公众号附录5：https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html2. 疑难杂症的处理2.1 在微信中失败，在企业微信正常这个主要针对的是企业微信的自建应用（猜测是企业微信的老应用没有同步问题），可以尝试一下这个操作：可信域名删掉，保存；再重新填写，保存停用当前应用，然后再启用应用参考：https://developer.work.weixin.qq.com/community/question/detail?content_id=164827725388820618882.2 在iOS系统失败，在安卓正常问题原因：获取签名时，安卓应为实时域名，iOS应为首次进入域名解决办法：解法1：链接通过location.href这类方法打开，而不是使用vue、react的路由。解法2：判断是iOS且非企业微信时，把页面首次地址记录到window下的变量中，获取签名时取该地址作为签名地址，可参考https://blog.csdn.net/Sweet__Cat/article/details/88591688解法2,vue2处理方案代码：// router.js在页面首次进入时把url存放到`window._ios_entry_url_`变量中 // 判断是否是ios系统 export function isIos () { return /(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent) } // 判断是否是企业微信 export function isWxWork () { return /wxwork/i.test(navigator.userAgent) } router.beforeEach((to, from, next) => { // 企业微信没有该问题，因此只针对微信内做处理 if (isIos() && !isWxWork() && !window._ios_entry_url_) { // 此处不可使用location.replace window._ios_entry_url_ = window.location.href } next() }) // 在需要获取签名的地方如page.vue，传参url时区分iOS和企业微信 let webUrl = window.location.href if (isIos() && !isWxWork() && window._ios_entry_url_) { // 在ios系统，vue history模式的url不会变，会一直是首次访问的链接：https://www.fengerzh.com/jssdk-invalid-signature/ // 否则会出现签名失败的问题 webUrl = window._ios_entry_url_ } // 发起请求获取签名 getWechatConfig({ webUrl }) 参考：https://developers.weixin.qq.com/community/develop/doc/000ae2cb950808f90d8bc415551800
项目背景一个工单管理系统，工单处理流程（简化后）是： 创建工单 -> 分派工单 -> 处理工单 -> 完成工单。表结构数据库用的是SQL Server（MySQL其实也类似），有两张表： 工单表Order 和 工单记录表OrderRecord。 工单记录表里面记录者每张工单的操作记录，是一对多的关系。 工单表Order： idtitlestatus1工单122工单213工单33id是自增idtitle是工单的标题status是工单当前状态， 1(待处理), 2(处理中), 3(已完成) 工单记录表OrderRecord ：idrecordTypeoperatororderIdcreatedAtremark11张三12021-07-01 22李四12021-07-02 33张三12021-07-03 44张三12021-07-04 52李四12021-07-05需要返工，重新分派工单61王五22021-07-05 73王五12021-07-06 84王五12021-07-07 id是自增idrecordType记录类型的枚举，有：1(创建工单), 2(分派工单), 3(处理工单), 4(完成工单)。operator操作者名字。orderId该记录所属的工单id，对应Order表的id字段。createdAt 记录创建时间。remark 备注信息。查询一张工单的操作记录，如查询工单id为1的操作记录： select * from OrderRecord where orderId=1，配合程序得到的结果如下：记录ID操作类型操作人操作时间备注1创建工单张三2021-07-01 2分派工单李四2021-07-02 3处理工单张三2021-07-03 4完成工单张三2021-07-04 5分派工单李四2021-07-05需要返工，重新分派工单7处理工单王五2021-07-06 7完成工单李四2021-07-07 通过这个操作记录可以看到，工单处理完成后，可能由于一些特殊原因处理地不好，这张工单中途被李四重新分派出去了，然后由王五进行了完工。需求为了统计员工的工作量来做绩效管理，所以老板想要统计每个月员工的工单完工情况，需要导出一个数据报表。因为特殊原因，工单的分派者的工作量很大，所以老板希望在统计报表里面看到工单分派员是谁（工单有多次分派情况时以最后一次分派为准），报表内容大概如下：工单ID工单名称完工时间分派员1工单12021-07-07李四3工单32021-07-08张三需求应该很容易理解，就是查询某个月的完工单的情况，而且需要从工单的记录表里面找到最新的一个分派员，这个SQL语句应该怎么写呢？需求实现工单的完工时间和分派员需要从 OrderRecord 表里面取值，而且一张工单会有多次分派、多次完工的情况，比较复杂。我们一步一步来实现这个过程。1. 查询所有的完工单通过表的定义，我们知道，当status=3时表示工单已完工了，所以我们首先实现查询所有的完工单：select * from Order status=32. 查询某个月份的完工单每个月的完工单，我们暂时不考虑给Order表加字段的情况，而是通过 OrderRecord 进行联表查询。 假设每个工单只能完工一次（即recordType=4的情况只会出现一次），那么我们可以很轻松地联表查询去做到，SQL这样写就可以了：select * from Order o left join OrderRecord or on or.orderId=o.id where o.status=3 and or.recordType=4 and or.createdAt between '2021-07-01' and '2021-08-01'但是，咱们这里的工单是允许完工多次的，上面这条语句会导致出现多条工单id一样的记录，而且没法用distinct去重，所以上面这条SQL语句没法满足需求。上面知道工单1有两次完工的记录，我们查询该工单的完工记录的SQL语句如下：select * from OrderRecord where ordeId=1 and recordType=4得到的结果如下：idrecordTypeoperatororderIdcreatedAtremark44张三12021-07-04 84王五12021-07-07 我们的需求是：实际的完工时间是取最后一次完工时间，所以要取记录ID为8的这一条作为该工单的完工时间。即按时间倒序排取第一条： select top 1 * from OrderRecord where ordeId=1 and recordType=4 order by createdAt desc。但如果和工单表联表查询的话没法使用top 1这种写法呀，所以笔者查到了 SQL Server内部提供了max方法，可用于按最大值来排序。最后查询查询某个月份的完工单的SQL语句如下：select o.id, o.title, or.createdAt from Order o left join (select recordType,max(createdAt) as createdAt,orderId from OrderRecord where recordType=4 group by recordType,orderId) or on or.orderId=o.id where o.status=3 and or.createdAt between '2021-07-01' and '2021-08-01'3. 查询某个月份的完工单的分派员由于工单允许多次分派、多次完工，让我们的SQL语句写得多么曲折，怕了怕了。。。上一步用select recordType,max(createdAt) as createdAt,orderId from OrderRecord where recordType=4 group by recordType,orderId来实现了完工时间的去重，这次我们要实现分派员的去重。依样画葫芦可以吗？比如分派员的语句我们尝试这样用（recordType=2表示分派工作）：select recordType,max(createdAt) as createdAt,orderId,operator from OrderRecord where recordType=2 group by recordType,orderId,operator由于我们加了一个 operator的字段，导致 group by 也没法去重了，所以没法依样画葫芦。咱们换个思路：先查询出去重后的分派记录的id，然后再查询分派员的名字，即：select operator,orderId from OrderRecord where id in ( select id from ( select max(id) as id,orderId from OrderRecord where recordType=2 group by recordType,orderId ) tempIds )从最中间的语句查询出去重后的记录id临时表，然后再从这个临时表里面得到操作者等字段。最终的查询某个月份的完工单的分派员的SQL语句如下： select o.id, o.title, or.createdAt,ocs.operator as orderSender from Order o left join (select recordType,max(createdAt) as createdAt,orderId from OrderRecord where recordType=4 group by recordType,orderId) or on or.orderId=o.id left join ( select operator,orderId from OrderRecord where id in ( select id from ( select max(id) as id,orderId from OrderRecord where recordType=2 group by recordType,orderId ) tempIds ) ) as ors on ors.orderId=o.id where o.status=3 and or.createdAt between '2021-07-01' and '2021-08-01' and ocs.operator='张三' ocs.operator as orderSender： 查询分派员的记录临表别名是ocs，避免命名冲突分派员的字段别名取了orderSender，这样就可以查出最后的结果集了：idtitlecreatedAtorderSender1工单12021-07-07李四3工单32021-07-08张三优化美滋滋地完成了需求，这么难的SQL都写出来了，测试也完全ok，信心膨胀到极点，然后上线就翻车了。生产的工单表有1w多条，工单记录表20多w条，上面那条SQL语句执行时间需要接近 20秒，20秒啊，整个人都傻掉了。OrderRecord表之前有对orderId做了索引，理论上速度会快一些的。上面那条SQL语句为了兼顾分派员的查询，实在是太慢了，嵌套太多，需要想办法优化。这个SQL已经想不到啥优化办法了，所以笔者想到的一个办法是在Order表新增一个分派员的字段，这样就可以不用去重联表查了，在分派员操作时同时多记录操作者到Order表，这样以空间换时间的思路来实现。即Order表新增一个orderSender字段：alter table Order add orderSender varchar(20) null，查询语句可以简化为：select o.id, o.title, or.createdAt,o.orderSender from Order o left join (select recordType,max(createdAt) as createdAt,orderId from OrderRecord where recordType=4 group by recordType,orderId) or on or.orderId=o.id where o.status=3 and or.createdAt between '2021-07-01' and '2021-08-01' and o.operator='张三'试了一下，上面语句的执行时间基本可以限制在1秒左右就完成，速度可以提升95%以上。进一步优化的话，我们可以把完工时间也放到Order表，这样就可以完全做到不链表进行查询了。原理是一样的，这里就不写这部分了。新增了一个字段，但我们需要解决存量数据的问题，即需要把以前的工单分派员更新到新增的orderSender字段中，用这个语句：update Order set orderSender=oc.operator from ( select operator,orderId from OrderRecord where id in ( select id from ( select max(id) as id,orderId from OrderRecord where recordType=2 group by recordType,orderId ) tempIds ) ) oc where oc.orderId=Order.id PS： 之前没有想到上面这个语句，所以笔者折腾了一下存储过程，即将查询出来的记录id，通过循环去一个个update，但效率很慢，执行了好久好久。存储过程的写法如下：CREATE PROCEDURE p_orderSender as BEGIN DECLARE @operator VARCHAR(20), @orderId INT -- 定义游标 DECLARE cur CURSOR FOR select operator,orderId from OrderRecord where id in ( select id from ( select max(id) as id,orderId from OrderRecord where recordType=2 group by recordType,orderId ) tempIds ) OPEN cur fetch next from cur into @operator,@orderId while @@fetch_status<>-1 begin --您要执行的操作写在这里 update Order set orderSender=@operator where id=@orderId fetch next from cur into @operator,@orderId end close cur deallocate cur END;
前提给一个纯原生的项目添加eslint，如：remember-scroll。目标做到项目运行时自动提示语法错误。做到 git commit 时校验语法错误。步骤1. 安装和配置eslint安装eslintnpm install eslint --save-dev初始化.eslintrc.jsnpx eslint --init可能还需配置一些global变量和exclude node_modules，博主这里最终的.eslint.js如下：module.exports = { "env": { "browser": true, "es2021": true }, "extends": "eslint:recommended", "parserOptions": { "ecmaVersion": 12, "sourceType": "module" }, "rules": { }, globals: { "process": true, "__dirname": true, "module": true, } }; 在根目录添加.eslintignore:node_modules dist2.git commit添加校验使用lint-staged 添加git commit钩子npx mrm@2 lint-staged这个命令会根据package.json里面的依赖工具，自动安装和配置好 husky和 lint-staged，跑完后就能在git commit。该命令还会更改我们的package.json文件，注意其中有一行是： "lint-staged": { "*.js": "eslint --cache --fix" }这就是在git commit时执行的命令： eslint --cache --fix。--cache表示每次只校验有修改的文件，该参数会在根目录生成一个.eslintcache文件，我们需要在.gitignore中忽略这个文件。如果想每次都全量校验所有文件，可以把该参数去掉，去掉后就不会生成.eslintcache文件了。--fix 表示自动修复。3.项目运行时自动提示语法错误由于我项目使用的是rollup来打包，因此需要在rollup.config.js里面去新增一个eslint相关的插件来做到，如果是webpack项目请自行搜索对应的插件或配置方法。安装@rollup/plugin-eslintnpm install @rollup/plugin-eslint --save-devrollup.config.js配置import eslint from '@rollup/plugin-eslint' export default { // ... plugins: [ eslint({ throwOnError: true, throwOnWarning: true, include: ['src/**'], exclude: ['node_modules/**'] }), // ... ] }至此，已经实现了我们的前面提到的两个目标啦，可以测试一下。
有个企业微信的应用，最近想要在页面里面展示当前用户的微信头像。用户的头像是在授权登录后，可以拿到userId，然后服务端再通过获取用户个人信息的接口拿到用户的个人信息，包括头像，存到数据库里。为了不增加额外的存储成本，所以不会将用户的头像再上传到我们自己的存储服务，使用时是直接使用微信头像对应的url从微信服务器中取，得到的微信头像url如下：http://wework.qpic.cn/bizmail/ia7iaXMXfXKuOiapqsvvaibY332FrQpdWPGrbbfGhIeObj2dmXZPJfFLOA/0问题如果我们的前端页面想要直接通过 插入到页面中，会出现跨域问题:Access to image at 'http://wework.qpic.cn/bizmail/ia7iaXMXfXKuOiapqsvvaibY332FrQpdWPGrbbfGhIeObj2dmXZPJfFLOA/0' from origin 'http://${your-domain}' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.解决思路上面说了，为了省成本，我们不会在自己的服务器上再存一份头像的图片，所以拷贝到自己服务器的方案就不考虑了。主要两个方案：后端写个接口帮忙获取图片，获取到的图片转为base64返回给前端。前端拼接特殊url前缀，通过nginx代理转发到微信图片服务器。两个方案其实都差不多，我们来看下第二种方案的具体思路。前端发起/wechat_image前缀的请求，url上携带原微信头像的路径，如:/wechat_image?url=http://wework.qpic.cn/bizmail/ia7iaXMXfXKuOiapqsvvaibY332FrQpdWPGrbbfGhIeObj2dmXZPJfFLOA/0&host=wework.qpic.cnnginx配置拦截 location /wechat_image的代理。实现步骤1. 处理url从接口拿到avatar的url：http://wework.qpic.cn/bizmail/ia7iaXMXfXKuOiapqsvvaibY332FrQpdWPGrbbfGhIeObj2dmXZPJfFLOA/0我们可以使用new URL来解析一个url，这里以vue代码为例： 2. nginx拦截配置考虑到微信头像的域名可能有多个，所以我们的代理需要根据url动态改变。但nginx有个安全限制，就是如果proxy_pass后面是一个参数变量，它会报一个no resolver defined to resolve的错误。因此需要在http节点配置一下：resolver 8.8.8.8 ipv6=off;http { # 解决no resolver defined to resolve错误 resolver 8.8.8.8 ipv6=off; server { listen 80; location /wechat_image { proxy_set_header Host "$arg_host"; proxy_pass $arg_url; } # 其他的配置... } }PS: 在nginx中，可以通过$arg_name来获取url后的query参数。可以看到使用了两个参数$arg_url和$arg_host，它们就是上一步在url上拼接的参数，在这个例子里面，他们分别是http://wework.qpic.cn/bizmail/ia7iaXMXfXKuOiapqsvvaibY332FrQpdWPGrbbfGhIeObj2dmXZPJfFLOA/100 和 wework.qpic.cn。本来host参数也是可以在url上截取的，但在nginx中不好处理，所以索性传多一个host参数了。实践证明， proxy_set_header Host "$arg_host";是必不可少的，不然的话微信图片服务器不会返回真正的图片。
前言大概一年前写了个小小的js插件 remember-scroll，并且分享了一篇文章：用Class写一个记住用户离开位置的js插件,是一个纯js库，功能是在用户再次进入页面时能自动定位到上一次浏览的位置，使用webpack+babel打包，里面的webpack和babel的配置至今看来也算是很典型的。前端打包工具有很多——webpack,gulp,rollup等等，网上有很多文章分析它们分别更适合哪些场景，webpack更适合打包组件库、应用程序之类的应用，而rollup更适合打包纯js的类库。因此笔者一直有想法尝试将 remember-scroll 的打包工具由webpack更换为rollup，从实际应用的角度来对比一下两者的区别。从零配置rollup安装rollup和一些插件npm i rollup rollup-plugin-uglify rollup-plugin-filesize @rollup/plugin-node-resolve @rollup/plugin-commonjs -Drollup-plugin-uglify 用于压缩混淆打包后的js。rollup-plugin-filesize打包后在控制台显示文件大小。@rollup/plugin-node-resolve让 rollup 能够识别node_modules的第三方模块。@rollup/plugin-commonjs将 CommonJS 的模块转换为 ES2015 供 rollup 处理。添加babelnpm i @rollup/plugin-babel @babel/core @babel/plugin-transform-runtime @babel/preset-env core-js@2 -D@rollup/plugin-babel rollup 的babel插件。@babel/core babel核心。@babel/plugin-transform-runtime 用于避免污染全局函数（不是必须要用到，但作为类库最好要加上）。@babel/preset-env 自动根据目标浏览器注入相关的polyfill。core-js polyfill的类库，这里使用的是2.x版本（使用3会增加包的大小）。根目录下的babel.config.js如下：const presets = [ [ '@babel/env', { useBuiltIns: 'usage', corejs: { version: 2 } }, ], ] const plugins = [ '@babel/plugin-transform-runtime' ] module.exports = { presets, plugins }根目录下新建rollup.config.js,全部配置如下：import filesize from 'rollup-plugin-filesize' import babel from '@rollup/plugin-babel' import resolve from '@rollup/plugin-node-resolve' import { uglify } from 'rollup-plugin-uglify' import commonjs from '@rollup/plugin-commonjs' const isProd = process.env.NODE_ENV === 'production' export default { input: 'src/index.js', output: { file: isProd ? 'dist/remember-scroll.min.js' : 'dist/remember-scroll.js', format: 'umd', exports: 'default', name: 'RememberScroll', }, plugins: [ resolve(), commonjs(), filesize(), babel({ babelHelpers: 'runtime', exclude: ['node_modules/**'] }), (isProd && uglify()) ] }package.json打包命令如下： "build": "rollup -c --environment NODE_ENV:production && rollup -c", "dev": "rollup -c --watch",总之，一切配置都是与之前webpack版本的一样，都使用了babel。npm run build就可以将资源打包到dist了，接下来我们对比一下webpack和rollup两个工具打出来的体积有啥区别。webpack和rollup打包体积对比笔者特地建了一个同时有rollup和webpack打包出来的资源的分支，大家可以直接看下github上feature/webpack_rollup分支的remember-scroll/dist，对比结果如下：-webpackrollup开发模式大小52.8KB19.46KB生产打包大小10.3KB7.66KB生产包gzip后大小4.1KB3.4KB可见，rollup打包出来的体积都比webpack略小一些，通过查看打包出来的代码，webpack打包出来的文件里面有很多__webpack_require__工具函数的定义，可读性也很差，而rollup打包出来的js会简单一点。项目的master分支已经改为使用rollup进行构建，feature/webpack分支保留了之前webpack的配置，感兴趣的同学可以去github上详细了解下。不得不说，从打包体积上来看，使用rollup构建无疑是更适合的。package.json的main指向问题笔者之前遇到过一个问题是package.json的main到底应该是指向构建后的开发版本还是生产版本呢？关于package.json中main字段的指向问题这篇文章给了答案：main应该指向开发版本。这里会有一个疑问：引用开发版本的包体积很大，岂不是让我的应用打包上线版本很大？为了验证上面 package.json 的main指向开发或生产版本有什么不同，笔者这里直接实战做个对比。使用VueCli v4.5.9 新建一个vue项目，然后在App.vue引入不同工具打包而成的remember-scroll，再npm run build打包该vue项目，对比打包出来chunk-vendors.[hash].js的体积。引入的npm包默认会打包进chunk-vendors，app.js的增量体积都是一样的就不作对比了。-vue打包webpack开发版(52.8KB)webpack生产版(10.3KB)rollup开发版(19.46KB,推荐)rollup生产版(7.66KB)Size89.42134.9799.3497.2996.96Gzip32.0440.4734.6034.5234.51可以看到，使用rollup打包的，无论main指向开发版还是生产版，gzip后几乎一致，但webpack打包出来的，main指向开发版时体积会相差非常大。所以使用webpack打包的插件，一般都是会根据NODE_ENV来加载对应的包，NODE_ENV === 'production'时指向压缩后的生产版本。比如像下面这样，在根目录新建一个index.js，package.json的main指向该文件，然后在js中写上：if (process.env.NODE_ENV === 'production') { module.exports = require('./dist/remember-scroll.min.js') } else { module.exports = require('./dist/remember-scroll.js') }倘若各位以后要写一个用webpack打包的插件，要特别注意这一点。而如果用rollup打包的，其实就不用在意这个细节啦，在这个环节rollup又比webpack更香一点哈哈哈。总结通过实战，功能不变且浏览器兼容性一致的情况下，对remember-scroll这个js库来说，使用rollup打包确实比webpack会更合适一些。所以如果我们以后要做技术选型，对于纯js的类库，选择使用rollup会更合适一点。当然，rollup和webpack都是作为构建工具，它们都有着各自的优势和各自的使用场景，利用好它们的优点就可以了。
最近负责的新项目用到了qiankun，写篇文章分享下实战中遇到的一些问题和思考。示例代码： https://github.com/fengxianqi/qiankun-example。在线demo：http://qiankun.fengxianqi.com/单独访问在线子应用：subapp/sub-vuesubapp/sub-react为什么要用qiankun项目有个功能需求是需要内嵌公司内部的一个现有工具，该工具是独立部署的且是用React写的，而我们的项目主要技术选型是vue，因此需要考虑嵌入页面的方案。主要有两条路：iframe方案qiankun微前端方案两种方案都能满足我们的需求且是可行的。不得不说，iframe方案虽然普通但很实用且成本也低，iframe方案能覆盖大部分的微前端业务需求，而qiankun对技术要求更高一些。技术同学对自身的成长也是有强烈需求的，因此在两者都能满足业务需求时，我们更希望能应用一些较新的技术，折腾一些未知的东西，因此我们决定选用qiankun。项目架构后台系统一般都是上下或左右的布局。下图粉红色是基座，只负责头部导航，绿色是挂载的整个子应用，点击头部导航可切换子应用。参考官方的examples代码，项目根目录下有基座main和其他子应用sub-vue，sub-react，搭建后的初始目录结构如下： ├── common //公共模块 ├── main // 基座 ├── sub-react // react子应用 └── sub-vue // vue子应用基座是用vue搭建，子应用有react和vue。基座配置基座main采用是的Vue-Cli3搭建的，它只负责导航的渲染和登录态的下发，为子应用提供一个挂载的容器div，基座应该保持简洁（qiankun官方demo甚至直接使用原生html搭建），不应该做涉及业务的操作。qiankun这个库只需要在基座引入，在main.js中注册子应用,为了方便管理，我们将子应用的配置都放在：main/src/micro-app.js下。const microApps = [ { name: 'sub-vue', entry: '//localhost:7777/', activeRule: '/sub-vue', container: '#subapp-viewport', // 子应用挂载的div props: { routerBase: '/sub-vue' // 下发路由给子应用，子应用根据该值去定义qiankun环境下的路由 } }, { name: 'sub-react', entry: '//localhost:7788/', activeRule: '/sub-react', container: '#subapp-viewport', // 子应用挂载的div props: { routerBase: '/sub-react' } } ] export default microApps 然后在src/main.js中引入import Vue from 'vue'; import App from './App.vue'; import { registerMicroApps, start } from 'qiankun'; import microApps from './micro-app'; Vue.config.productionTip = false; new Vue({ render: h => h(App), }).$mount('#app'); registerMicroApps(microApps, { beforeLoad: app => { console.log('before load app.name====>>>>>', app.name) }, beforeMount: [ app => { console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name); }, ], afterMount: [ app => { console.log('[LifeCycle] after mount %c%s', 'color: green;', app.name); } ], afterUnmount: [ app => { console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name); }, ], }); start();在App.vue中，需要声明micro-app.js配置的子应用挂载div（注意id一定要一致），以及基座布局相关的，大概这样： 头部导航 这样，基座就算配置完成了。项目启动后，子应用将会挂载到 中。子应用配置一、vue子应用用Vue-cli在项目根目录新建一个sub-vue的子应用，子应用的名称最好与父应用在src/micro-app.js中配置的名称一致（这样可以直接使用package.json中的name作为output）。新增vue.config.js，devServer的端口改为与主应用配置的一致,且加上跨域headers和output配置。// package.json的name需注意与主应用一致 const { name } = require('../package.json') module.exports = { configureWebpack: { output: { library: `${name}-[name]`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_${name}`, } }, devServer: { port: process.env.VUE_APP_PORT, // 在.env中VUE_APP_PORT=7788，与父应用的配置一致 headers: { 'Access-Control-Allow-Origin': '*' // 主应用获取子应用时跨域响应头 } } }新增src/public-path.js(function() { if (window.__POWERED_BY_QIANKUN__) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-undef __webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}/`; return; } // eslint-disable-next-line no-undef __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } })();src/router/index.js改为只暴露routes，new Router改到main.js中声明。改造main.js，引入上面的public-path.js，改写render，添加生命周期函数等，最终如下：import './public-path' // 注意需要引入public-path import Vue from 'vue' import App from './App.vue' import routes from './router' import store from './store' import VueRouter from 'vue-router' Vue.config.productionTip = false let instance = null function render (props = {}) { const { container, routerBase } = props const router = new VueRouter({ base: window.__POWERED_BY_QIANKUN__ ? routerBase : process.env.BASE_URL, mode: 'history', routes }) instance = new Vue({ router, store, render: (h) => h(App) }).$mount(container ? container.querySelector('#app') : '#app') } if (!window.__POWERED_BY_QIANKUN__) { render() } export async function bootstrap () { console.log('[vue] vue app bootstraped') } export async function mount (props) { console.log('[vue] props from main framework', props) render(props) } export async function unmount () { instance.$destroy() instance.$el.innerHTML = '' instance = null }至此，基础版本的vue子应用配置好了，如果router和vuex不需用到，可以去掉。二、react子应用通过npx create-react-app sub-react新建一个react应用。新增.env文件添加PORT变量，端口号与父应用配置的保持一致。为了不eject所有webpack配置，我们用react-app-rewired方案复写webpack就可以了。首先npm install react-app-rewired --save-dev新建sub-react/config-overrides.jsconst { name } = require('./package.json'); module.exports = { webpack: function override(config, env) { // 解决主应用接入后会挂掉的问题：https://github.com/umijs/qiankun/issues/340 config.entry = config.entry.filter( (e) => !e.includes('webpackHotDevClient') ); config.output.library = `${name}-[name]`; config.output.libraryTarget = 'umd'; config.output.jsonpFunction = `webpackJsonp_${name}`; return config; }, devServer: (configFunction) => { return function (proxy, allowedHost) { const config = configFunction(proxy, allowedHost); config.open = false; config.hot = false; config.headers = { 'Access-Control-Allow-Origin': '*', }; return config; }; }, };新增src/public-path.js。if (window.__POWERED_BY_QIANKUN__) { // eslint-disable-next-line __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }改造index.js,引入public-path.js，添加生命周期函数等。import './public-path' import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; function render() { ReactDOM.render( <App />, document.getElementById('root') ); } if (!window.__POWERED_BY_QIANKUN__) { render(); } /** * bootstrap 只会在微应用初始化的时候调用一次，下次微应用重新进入时会直接调用 mount 钩子，不会再重复触发 bootstrap。 * 通常我们可以在这里做一些全局变量的初始化，比如不会在 unmount 阶段被销毁的应用级别的缓存等。 */ export async function bootstrap() { console.log('react app bootstraped'); } /** * 应用每次进入都会调用 mount 方法，通常我们在这里触发应用的渲染方法 */ export async function mount(props) { console.log(props); render(); } /** * 应用每次 切出/卸载 会调用的方法，通常在这里我们会卸载微应用的应用实例 */ export async function unmount() { ReactDOM.unmountComponentAtNode(document.getElementById('root')); } /** * 可选生命周期钩子，仅使用 loadMicroApp 方式加载微应用时生效 */ export async function update(props) { console.log('update props', props); } serviceWorker.unregister();至此，基础版本的react子应用配置好了。进阶全局状态管理qiankun通过initGlobalState, onGlobalStateChange, setGlobalState实现主应用的全局状态管理，然后默认会通过props将通信方法传递给子应用。先看下官方的示例用法：主应用：// main/src/main.js import { initGlobalState } from 'qiankun'; // 初始化 state const initialState = { user: {} // 用户信息 }; const actions = initGlobalState(initialState); actions.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); actions.setGlobalState(state); actions.offGlobalStateChange();子应用：// 从生命周期 mount 中获取通信方法，props默认会有onGlobalStateChange和setGlobalState两个api export function mount(props) { props.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); }); props.setGlobalState(state); }这两段代码不难理解，父子应用通过onGlobalStateChange这个方法进行通信，这其实是一个发布-订阅的设计模式。ok，官方的示例用法很简单也完全够用，纯JavaScript的语法，不涉及任何的vue或react的东西，开发者可自由定制。如果我们直接使用官方的这个示例，那么数据会比较松散且调用复杂，所有子应用都得声明onGlobalStateChange对状态进行监听，再通过setGlobalState进行更新数据。因此，我们很有必要对数据状态做进一步的封装设计。笔者这里主要考虑以下几点：主应用要保持简洁简单，对子应用来说，主应用下发的数据就是一个很纯粹的object，以便更好地支持不同框架的子应用，因此主应用不需用到vuex。vue子应用要做到能继承父应用下发的数据，又支持独立运行。子应用在mount声明周期可以获取到最新的主应用下发的数据，然后将这份数据注册到一个名为global的vuex module中，子应用通过global module的action动作进行数据的更新，更新的同时自动同步回父应用。因此，对子应用来说，它不用知道自己是一个qiankun子应用还是一个独立应用，它只是有一个名为global的module，它可通过action更新数据，且不再需要关心是否要同步到父应用（同步的动作会封装在方法内部，调用者不需关心），这也是为后面支持子应用独立启动开发做准备。react子应用同理（笔者react用得不深就不说了）。主应用的状态封装主应用维护一个initialState的初始数据，它是一个object类型，会下发给子应用。// main/src/store.js import { initGlobalState } from 'qiankun'; import Vue from 'vue' //父应用的初始state // Vue.observable是为了让initialState变成可响应：https://cn.vuejs.org/v2/api/#Vue-observable。 let initialState = Vue.observable({ user: {}, }); const actions = initGlobalState(initialState); actions.onGlobalStateChange((newState, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log('main change', JSON.stringify(newState), JSON.stringify(prev)); for (let key in newState) { initialState[key] = newState[key] } }); // 定义一个获取state的方法下发到子应用 actions.getGlobalState = (key) => { // 有key，表示取globalState下的某个子级对象 // 无key，表示取全部 return key ? initialState[key] : initialState } export default actions;这里有两个注意的地方：Vue.observable是为了让父应用的state变成可响应式，如果不用Vue.observable包一层，它就只是一个纯粹的object，子应用也能获取到，但会失去响应式，意味着数据改变后，页面不会更新。getGlobalState方法，这个是有争议的，大家在github上有讨论：https://github.com/umijs/qiankun/pull/729。一方面，作者认为getGlobalState不是必须的，onGlobalStateChange其实已经够用。另一方面，笔者和其他提pr的同学觉得有必要提供一个getGlobalState的api，理由是get方法更方便使用，子应用有需求是不需一直监听stateChange事件，它只需要在首次mount时通过getGlobalState初始化一次即可。在这里，笔者先坚持己见让父应用下发一个getGlobalState的方法。由于官方还不支持getGlobalState，所以需要显示地在注册子应用时通过props去下发该方法：import store from './store'; const microApps = [ { name: 'sub-vue', entry: '//localhost:7777/', activeRule: '/sub-vue', }, { name: 'sub-react', entry: '//localhost:7788/', activeRule: '/sub-react', } ] const apps = microApps.map(item => { return { ...item, container: '#subapp-viewport', // 子应用挂载的div props: { routerBase: item.activeRule, // 下发基础路由 getGlobalState: store.getGlobalState // 下发getGlobalState方法 }, } }) export default microAppsvue子应用的状态封装前面说了，子应用在mount时会将父应用下发的state，注册为一个叫global的vuex module，为了方便复用我们封装一下：// sub-vue/src/store/global-register.js /** * * @param {vuex实例} store * @param {qiankun下发的props} props */ function registerGlobalModule(store, props = {}) { if (!store || !store.hasModule) { return; } // 获取初始化的state const initState = props.getGlobalState && props.getGlobalState() || { menu: [], user: {} }; // 将父应用的数据存储到子应用中，命名空间固定为global if (!store.hasModule('global')) { const globalModule = { namespaced: true, state: initState, actions: { // 子应用改变state并通知父应用 setGlobalState({ commit }, payload) { commit('setGlobalState', payload); commit('emitGlobalState', payload); }, // 初始化，只用于mount时同步父应用的数据 initGlobalState({ commit }, payload) { commit('setGlobalState', payload); }, }, mutations: { setGlobalState(state, payload) { // eslint-disable-next-line state = Object.assign(state, payload); }, // 通知父应用 emitGlobalState(state) { if (props.setGlobalState) { props.setGlobalState(state); } }, }, }; store.registerModule('global', globalModule); } else { // 每次mount时，都同步一次父应用数据 store.dispatch('global/initGlobalState', initState); } }; export default registerGlobalModule;main.js中添加global-module的使用：import globalRegister from './store/global-register' export async function mount(props) { console.log('[vue] props from main framework', props) globalRegister(store, props) render(props) }可以看到，该vuex模块在子应用mount时，会调用initGlobalState将父应用下发的state初始化一遍，同时提供了setGlobalState方法供外部调用，内部自动通知同步到父应用。子应用在vue页面使用时如下：export default { computed: { ...mapState('global', { user: state => state.user, // 获取父应用的user信息 }), }, methods: { ...mapActions('global', ['setGlobalState']), update () { this.setGlobalState('user', { name: '张三' }) } }, };这样就达到了一个效果：子应用不用知道qiankun的存在，它只知道有这么一个global module可以存储信息，父子之间的通信都封装在方法本身了，它只关心本身的信息存储就可以了。ps: 该方案也是有缺点的，由于子应用是在mount时才会同步父应用下发的state的。因此，它只适合每次只mount一个子应用的架构（不适合多个子应用共存）；若父应用数据有变化而子应用又没触发mount，则父应用最新的数据无法同步回子应用。想要做到多子应用共存且父动态传子，子应用还是需要用到qiankun提供的onGlobalStateChange的api监听才行，有更好方案的同学可以分享讨论一下。该方案刚好符合笔者当前的项目需求，因此够用了，请同学们根据自己的业务需求来封装。子应用切换Loading处理子应用首次加载时相当于新加载一个项目，还是比较慢的，因此loading是不得不加上的。官方的例子中有做了loading的处理,但是需要额外引入import Vue from 'vue/dist/vue.esm'，这会增加主应用的打包体积（对比发现大概增加了100KB）。一个loading增加了100K，显然代价有点无法接受，所以需要考虑一种更优一点的办法。我们的主应用是用vue搭建的，而且qiankun提供了loader方法可以获取到子应用的加载状态，所以自然而然地可以想到：在main.js中子应用加载时，将loading 的状态传给Vue实例，让Vue实例响应式地显示loading。接下来先选一个loading组件：如果主应用使用了ElementUI或其他框架，可以直接使用UI库提供的loading组件。如果主应用为了保持简单没有引入UI库，可以考虑自己写一个loading组件，或者找个小巧的loading库，如笔者这里要用到的NProgress。npm install --save nprogress接下来是想办法如何把loading状态传给主应用的App.vue。经过笔者试验发现，new Vue方法返回的vue实例可以通过instance.$children[0]来改变App.vue的数据，所以改造一下main.js：// 引入nprogress的css import 'nprogress/nprogress.css' import microApps from './micro-app'; // 获取实例 const instance = new Vue({ render: h => h(App), }).$mount('#app'); // 定义loader方法，loading改变时，将变量赋值给App.vue的data中的isLoading function loader(loading) { if (instance && instance.$children) { // instance.$children[0] 是App.vue，此时直接改动App.vue的isLoading instance.$children[0].isLoading = loading } } // 给子应用配置加上loader方法 let apps = microApps.map(item => { return { ...item, loader } }) registerMicroApps(apps); start();PS: qiankun的registerMicroApps方法也监听到子应用的beforeLoad、afterMount等生命周期，因此也可以使用这些方法记录loading状态，但更好的用法肯定是通过loader参数传递。改造主应用的App.vue，通过watch监听isLoading 头部导航 至此，loading效果就实现了。虽然instance.$children[0].isLoading的操作看起来比较骚，但确实比官方的提供的例子成本小很多(体积增加几乎为0)，若有更好的办法，欢迎大家评论区分享。抽取公共代码不可避免，有些方法或工具类是所有子应用都需要用到的，每个子应用都copy一份肯定是不好维护的，所以抽取公共代码到一处是必要的一步。根目录下新建一个common文件夹用于存放公共代码，如上面的多个vue子应用都可以共用的global-register.js，或者是可复用的request.js和sdk之类的工具函数等。这里代码不贴了，请直接看demo。公共代码抽取后，其他的应用如何使用呢？ 可以让common发布为一个npm私包，npm私包有以下几种组织形式：npm指向本地file地址：npm install file:../common。直接在根目录新建一个common目录，然后npm直接依赖文件路径。npm指向私有git仓库: npm install git+ssh://xxx-common.git。发布到npm私服。本demo因为是基座和子应用都集合在一个git仓库上，所以采用了第一种方式，但实际应用时是发布到npm私服，因为后面我们会拆分基座和子应用为独立的子仓库，支持独立开发，后文会讲到。需要注意的是，由于common是不经过babel和pollfy的，所以引用者需要在webpack打包时显性指定该模块需要编译，如vue子应用的vue.config.js需要加上这句：module.exports = { transpileDependencies: ['common'], }子应用支持独立开发微前端一个很重要的概念是拆分，是分治的思想，把所有的业务拆分为一个个独立可运行的模块。从开发者的角度看，整个系统可能有N个子应用，如果启动整个系统可能会很慢很卡，而产品的某个需求可能只涉及到其中一个子应用，因此开发时只需启动涉及到的子应用即可，独立启动专注开发，因此是很有必要支持子应用的独立开发的。如果要支持，主要会遇到以下几个问题：子应用的登录态怎么维护？基座不启动时，怎么获取到基座下发的数据和能力？在基座运行时，登录态和用户信息是存放在基座上的，然后基座通过props下发给子应用。但如果基座不启动，只是子应用独立启动，子应用就没法通过props获取到所需的用户信息了。因此，解决办法只能是父子应用都得实现一套相同的登录逻辑。为了可复用，可以把登录逻辑封装在common中，然后在子应用独立运行的逻辑中添加登录相关的逻辑。// sub-vue/src/main.js import { store as commonStore } from 'common' import store from './store' if (!window.__POWERED_BY_QIANKUN__) { // 这里是子应用独立运行的环境，实现子应用的登录逻辑 // 独立运行时，也注册一个名为global的store module commonStore.globalRegister(store) // 模拟登录后，存储用户信息到global module const userInfo = { name: '我是独立运行时名字叫张三' } // 假设登录后取到的用户信息 store.commit('global/setGlobalState', { user: userInfo }) render() } // ... export async function mount (props) { console.log('[vue] props from main framework', props) commonStore.globalRegister(store, props) render(props) } // ...!window.__POWERED_BY_QIANKUN__表示子应用处于非qiankun内的环境，即独立运行时。此时我们依然要注册一个名为global的vuex module，子应用内部同样可以从global module中获取用户的信息，从而做到抹平qiankun和独立运行时的环境差异。PS：我们前面写的global-register.js写得很巧妙，能够同时支持两种环境，因此上面可以通过commonStore.globalRegister直接引用。子应用独立仓库随着项目发展，子应用可能会越来越多，如果子应用和基座都集合在同一个git仓库，就会越来越臃肿。若项目有CI/CD，只修改了某个子应用的代码，但代码提交会同时触发所有子应用构建，牵一发动全身，是不合理的。同时，如果某些业务的子应用的开发是跨部门跨团队的，代码仓库如何分权限管理又是一个问题。基于以上问题，我们不得不考虑将各个应用迁移到独立的git仓库。由于我们独立仓库了，项目可能不会再放到同一个目录下，因此前面通过npm i file:../common方式安装的common就不适用了，所以最好还是发布到公司的npm私服或采用git地址形式。qiankun-example为了更好展示，仍将所有应用都放在同一个git仓库下，请各位同学不要照抄。子应用独立仓库后聚合管理子应用独立git仓库后，可以做到独立启动独立开发了，这时候又会遇到问题：开发环境都是独立的，无法一览整个应用的全貌。虽然开发时专注于某个子应用时更好，但总有需要整个项目跑起来的时候，比如当多个子应用需要互相依赖跳转时，所以还是要有一个整个项目对所有子应用git仓库的聚合管理才行，该聚合仓库要求做到能够一键install所有的依赖(包括子应用)，一键启动整个项目。这里主要考虑了三种方案：使用git submodule。使用git subtree。单纯地将所有子仓库放到聚合目录下并.gitignore掉。使用lerna管理。git submodule和git subtree都是很好的子仓库管理方案，但缺点是每次子应用变更后，聚合库还得同步一次变更。考虑到并不是所有人都会使用该聚合仓库，子仓库独立开发时往往不会主动同步到聚合库，使用聚合库的同学就得经常做同步的操作，比较耗时耗力，不算特别完美。所以第三种方案比较符合笔者目前团队的情况。聚合库相当于是一个空目录，在该目录下clone所有子仓库，并gitignore，子仓库的代码提交都在各自的仓库目录下进行操作，这样聚合库可以避免做同步的操作。由于ignore了所有子仓库，聚合库clone下来后，仍是一个空目录，此时我们可以写个脚本scripts/clone-all.sh，把所有子仓库的clone命令都写上：# 子仓库一 git clone git@xxx1.git # 子仓库二 git clone git@xxx2.git然后在聚合库也初始化一个package.json，scripts加上: "scripts": { "clone:all": "bash ./scripts/clone-all.sh", },这样，git clone聚合库下来后，再npm run clone:all就可以做到一键clone所有子仓库了。前面说到聚合库要能够做到一键install和一键启动整个项目，我们参考qiankun的examples，使用npm-run-all来做这个事情。聚合库安装npm i npm-run-all -D。聚合库的package.json增加install和start命令： "scripts": { ... "install": "npm-run-all --serial install:*", "install:main": "cd main && npm i", "install:sub-vue": "cd sub-vue && npm i", "install:sub-react": "cd sub-react && npm i", "start": "npm-run-all --parallel start:*", "start:sub-react": "cd sub-react && npm start", "start:sub-vue": "cd sub-vue && npm start", "start:main": "cd main && npm start" },npm-run-all的--serial表示有顺序地一个个执行，--parallel表示同时并行地运行。配好以上，一键安装npm i,一键启动npm start。vscode eslint配置如果使用vscode，且使用了eslint的插件做自动修复，由于项目处于非根目录，eslint没法生效，所以还需要指定eslint的工作目录：// .vscode/settings.json { "eslint.workingDirectories": [ "./main", "./sub-vue", "./sub-react", "./common" ], "eslint.enable": true, "editor.formatOnSave": false, "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "search.useIgnoreFiles": false, "search.exclude": { "**/dist": true }, } 子应用互相跳转除了点击页面顶部的菜单切换子应用，我们的需求也要求子应用内部跳其他子应用，这会涉及到顶部菜单active状态的展示问题:sub-vue切换到sub-react，此时顶部菜单需要将sub-react改为激活状态。有两种方案：子应用跳转动作向上抛给父应用，由父应用做真正的跳转，从而父应用知道要改变激活状态，有点子组件$emit事件给父组件的意思。父应用监听history.pushState事件，当发现路由换了，父应用从而知道要不要改变激活状态。由于qiankun暂时没有封装子应用向父应用抛出事件的api，如iframe的postMessage，所以方案一有些难度，不过可以将激活状态放到状态管理中，子应用通过改变vuex中的值让父应用同步就行，做法可行但不太好，维护状态在状态管理中有点复杂了。所以我们这里选方案二，子应用跳转是通过history.pushState(null, '/sub-react', '/sub-react')的，因此父应用在mounted时想办法监听到history.pushState就可以了。由于history.popstate只能监听back/forward/go却不能监听history.pushState，所以需要额外全局复写一下history.pushState事件。// main/src/App.vue export default { methods: { bindCurrent () { const path = window.location.pathname if (this.microApps.findIndex(item => item.activeRule === path) >= 0) { this.current = path } }, listenRouterChange () { const _wr = function (type) { const orig = history[type] return function () { const rv = orig.apply(this, arguments) const e = new Event(type) e.arguments = arguments window.dispatchEvent(e) return rv } } history.pushState = _wr('pushState') window.addEventListener('pushState', this.bindCurrent) window.addEventListener('popstate', this.bindCurrent) this.$once('hook:beforeDestroy', () => { window.removeEventListener('pushState', this.bindCurrent) window.removeEventListener('popstate', this.bindCurrent) }) } }, mounted () { this.listenRouterChange() } }性能优化每个子应用都是一个完整的应用，每个vue子应用都打包了一份vue/vue-router/vuex。从整个项目的角度，相当于将那些模块打包了多次，会很浪费，所以这里可以进一步去优化性能。首先我们能想到的是通过webpack的externals或主应用下发公共模块进行复用。但是要注意，如果所有子应用都共用一个相同的模块，从长远来看，不利于子应用的升级，难以两全其美。现在觉得比较好的做法是：主应用可以下发一些自身用到的模块，子应用可以优先选择主应用下发的模块，当发现主应用没有时则自己加载；子应用也可以直接使用最新的版本而不用父应用下发的。这个方案参考自qiankun 微前端方案实践及总结-子项目之间的公共插件如何共享，思路说得非常完整，大家可以看看，本项目暂时还没加上该功能。部署现在网上qiankun部署相关的文章几乎搜不到，可能是觉得简单没啥好说的吧。但对于还不太熟悉的同学来说，其实会比较纠结qiankun部署的最佳部署方案是怎样的呢？所以觉得很有必要讲一下笔者这里的部署方案，供大家参考。方案如下：考虑到主应用和子应用共用域名时可能会存在路由冲突的问题，子应用可能会源源不断地添加进来，因此我们将子应用都放在xx.com/subapp/这个二级目录下，根路径/留给主应用。步骤如下：主应用main和所有子应用都打包出一份html,css,js,static，分目录上传到服务器，子应用统一放到subapp目录下，最终如：├── main │ └── index.html └── subapp ├── sub-react │ └── index.html └── sub-vue └── index.html配置nginx，预期是xx.com根路径指向主应用，xx.com/subapp指向子应用,子应用的配置只需写一份，以后新增子应用也不需要改nginx配置，以下应该是微应用部署的最简洁的一份nginx配置了。server { listen 80; server_name qiankun.fengxianqi.com; location / { root /data/web/qiankun/main; # 主应用所在的目录 index index.html; try_files $uri $uri/ /index.html; } location /subapp { alias /data/web/qiankun/subapp; try_files $uri $uri/ /index.html; } }nginx -s reload后就可以了。本文特地做了线上demo展示：整站（主应用）：http://qiankun.fengxianqi.com/单独访问子应用：subapp/sub-vue, 注意观察vuex数据的变化。subapp/sub-react遇到的问题一、react子应用启动后，主应用第一次渲染后会挂掉子应用的热重载居然会引得父应用直接挂掉，当时完全懵逼了。还好搜到了相关的issues/340,即在复写react的webpack时禁用掉热重载（加了下面配置禁用后会导致没法热重载，react应用在开发时得手动刷新了，是不是有点难受。。。）：module.exports = { webpack: function override(config, env) { // 解决主应用接入后会挂掉的问题：https://github.com/umijs/qiankun/issues/340 config.entry = config.entry.filter( (e) => !e.includes('webpackHotDevClient') ); // ... return config; } };二、Uncaught Error: application 'xx' died in status SKIP_BECAUSE_BROKEN: [qiankun] Target container with #subapp-viewport not existed while xx mounting!在本地dev开发时是完全正常的，这个问题是部署后在首次打开页面才会出现的，F5刷新后又会正常，只能在清掉缓存后复现一次。这个bug困扰了几天。错误信息很清晰，即主应用在挂载xx子应用时，用于装载子应用的dom不存在。所以一开始以为是vue做主应用时，#subapp-viewport还没来得及渲染，因此要尝试确保主应用mount后再注册子应用。// 主应用的main.js new Vue({ render: h => h(App), mounted: () => { // mounted后再注册子应用 renderMicroApps(); }, }).$mount('#root-app');但该办法不行，甚至setTimeout都用上了也不行，需另想办法。最后逐步调试发现是项目加载了一段高德地图的js导致的，该js在首次加载时会使用document.write去复写整个html，因此导致了#subapp-viewport不存在的报错，所以最后是要想办法去掉该js文件就可以了。小插曲：为什么我们的项目会加载这个高德地图js？我们项目也没有用到啊，这时我们陷入了一个思维误区：qiankun是阿里的，高德也是阿里的，qiankun不会偷偷在渲染时动态加载高德的js做些数据收集吧？非常惭愧会对一个开源项目有这个想法。。。实际上，是因为我司写组件库模板的小伙伴忘记移除调试时public/index.html用到的这个js了，当时还去评论issue了(捂脸哭)。把这个讲出来，是想说遇到bug时还是要先检查一下自己，别轻易就去质疑别人。最后本文从开始搭建到部署非常完整地分享了整个架构搭建的一些思路和实践，希望能对大家有所帮助。要提醒一下的是，本示例可能不一定最佳的实践，仅作为一个思路参考，架构是会随着业务需求不断调整变化的，只有合适的才是最好的。示例代码： https://github.com/fengxianqi/qiankun-example。在线demo：http://qiankun.fengxianqi.com/单独访问在线子应用：subapp/sub-vuesubapp/sub-react最后的最后，喜欢本文的同学还请能顺手给个赞和小星星鼓励一下，非常感谢看到这里。一些参考文章微前端在小米 CRM 系统的实践微前端实践可能是你见过最完善的微前端解决方案微前端在美团外卖的实践qiankun 微前端方案实践及总结
前端部署和提效：从静态到node再到负载均衡
前言相信很多前端同学对 vue 或 react 的开发很熟悉了，也知道如何去打包生成一个生产环境的包，但对于生产环境的部署可能有些同学了解比较少。小公司可能都是后端帮忙部署了，大公司会有专门的运维同学部署，对于生产环境的部署工作有些同学接触的不多，所以这次来分享和总结下前端项目部署相关的实战经验：从静态站点的部署，到 node 项目的部署，再到负载均衡的部署，顺便也会分享一下提高部署效率的脚本和方法。准备工作一台或多台服务器或虚拟机。一份 vue 或 react 项目的打包后的文件。一份 node 项目的源码。静态站点的部署静态站点的部署指的是前端的 html/css/js 资源的部署，如 vue 或 react 打包后生成的 html，css 和 js 文件，我们将这些文件上传到服务器后，通过 Nginx 将这些资源暴露到公网上。上传文件到服务器就是将文件人工将打包后的文件拷贝到服务器上，这个很简单，但如果每次都是人工拷贝，部署效率未免会低了一些，所以建议使用一些脚本或工具。在大公司，一般对服务器权限控制得很严格，可能需要各种跳板机或动态密码等，而大公司一般都有专门的运维人员或者 CI/CD 工具。小公司可能相对自由一些，可以允许个人直接 ssh 连接服务器，此时可以配合使用rsync或scp命令（Linux 或 Mac 系统）来一键上传文件到服务器上，给部署提效。在这里分享一下以前使用过的部署脚本,在前端项目根目录新建一个名为deploy.sh的文件：#!/bin/bash function deploy() { # 测试服务器 test_host="root@test_server_ip" # 生产服务器 prod_host="root@prod_server_ip" project_path="/srv/YourProject" if [ "$1" == "prod" ]; then target="$prod_host:$project_path" else target="$test_host:$project_path" fi rsync -azcuP ./dist/ --exclude node_modules --exclude coverage --exclude .env --exclude .nyc_output --exclude .git "$target" echo "deploy to $target" } deploy $@以上脚本的意思是将/dist目录下的所有文件，上传到对应服务器的/srv/YourProject目录下。测试环境的部署是直接在根目录运行./deploy.sh，该命令会将/dist目录直接上传到root@test_server_ip服务器上；生产环境的部署是在后面加一个参数./deploy.sh prod，这样可以实现多环境部署。更进一步的做法是将运行脚本的命令直接写进package.json中，如： "scripts": { "build": "vue-cli-service build --mode staging", "deploy": "npm run build && ./deploy.sh", "deploy:prod": "npm run build && ./deploy.sh prod" },这样，通过npm run deploy命令就可以实现直接打包并部署到测试环境了。如果你的公司目前还在用人工拷贝或 FTP 工具这种低效的部署方式，不妨试一下用上面的脚本来提效哦。PS：由于 rsync 命令只在 Linux 或 Mac 才有，所以只有开发环境是 Linux 或 Mac 的用户才可以运行哦，Windows 用户是没法跑这个命令的。编写网站的 conf上传文件到服务器后，就可以着手配置 nginx 了。一般 nginx 的配置都会放在/etc/nginx/conf.d目录下，我们在该目录新建一个test.conf作为该项目的配置：server { listen 80; server_name your-domain.com; # 域名 location / { root /srv/YourProject; # 网站源码根目录 index index.html; } location /api { proxy_pass http://localhost:8080; # 反向代理后端接口地址 } }一般来说，静态站点只需配置以上几个就可以了，server_name表示域名，需要先解析到服务器的公网 ip；root表示服务器中代码所在的位置，index指明了默认的处理文件是index.html；location /api是反向代理后端服务(这里假设了后端服务部署在本地 8080 端口)，即your-domain.com/api的请求都会转发到http://localhost:8080上，一般用该方法可以完美解决前端跨域的问题。修改 nginx 的 conf 后需要 reload 一下 nginx 服务：nginx -s reload测试如果上一步配置的域名是已经解析到服务器 ip 了的，就可以直接在公网上通过访问域名来访问你的站点了。如果不是，可以修改一下本机的 host 文件，使得配置的域名可以在本机访问；或者通过http://localhost来访问。node 项目的部署node 项目在开发时可以用node app.js这样的命令来启动服务，但在服务器上如果使用这个命令，退出服务器后 node 进程就停止了，所以需要借助可以让 node 进程 keep alive 的工具。现在一般都是用pm2。安装 pm2npm install -g pm2pm2 的一些常用命令：pm2 start app.js # 启动app.js应用程序 pm2 start app.js -i 4 # cluster mode 模式启动4个app.js的应用实例 # 4个应用程序会自动进行负载均衡 pm2 start app.js --name="api" # 启动应用程序并命名为 "api" pm2 start app.js --watch # 当文件变化时自动重启应用 pm2 start script.sh # 启动 bash 脚本 pm2 list # 列表 PM2 启动的所有的应用程序 pm2 monit # 显示每个应用程序的CPU和内存占用情况 pm2 show [app-name] # 显示应用程序的所有信息 pm2 logs # 显示所有应用程序的日志 pm2 logs [app-name] # 显示指定应用程序的日志 pm2 flush pm2 stop all # 停止所有的应用程序 pm2 stop 0 # 停止 id为 0的指定应用程序 pm2 restart all # 重启所有应用 pm2 reload all # 重启 cluster mode下的所有应用 pm2 gracefulReload all # Graceful reload all apps in cluster mode pm2 delete all # 关闭并删除所有应用 pm2 delete 0 # 删除指定应用 id 0 pm2 scale api 10 # 把名字叫api的应用扩展到10个实例 pm2 reset [app-name] # 重置重启数量 pm2 startup # 创建开机自启动命令 pm2 save # 保存当前应用列表 pm2 resurrect # 重新加载保存的应用列表 pm2 update # Save processes, kill PM2 and restore processes pm2 generate # Generate a sample json configuration file pm2 deploy app.json prod setup # Setup "prod" remote server pm2 deploy app.json prod # Update "prod" remote server pm2 deploy app.json prod revert 2 # Revert "prod" remote server by 2 pm2 module:generate [name] # Generate sample module with name [name] pm2 install pm2-logrotate # Install module (here a log rotation system) pm2 uninstall pm2-logrotate # Uninstall module pm2 publish # Increment version, git push and npm publish用 pm2 启动项目一般来说，我们可以直接使用pm2 start app.js --name="my-project"这样的命令来启动 node 项目，但是这样手打的命令会不好管理，所以我们一般会在 node 项目的根目录下新建一个pm2.json文件来指定 pm2 启动时的参数，如：{ "name": "my-project", "script": "./server/index.js", "instances": 2, "cwd": ".", "exec_mode" : "cluster" }name 表示 pm2 进程的名称，script 表示启动的文件入口，instances 表示启动的示例数量（一般建议数值不大于服务器处理器的核数），cmd 表示应用程序所在的目录。我们在服务器启动 node 项目时就可以直接pm2 start pm2.json。nginx 代理绑定域名node 项目使用 pm2 运行后，只是运行在服务器的某个端口，如http://server_ip:3000，如果该服务需要通过域名直接访问，则还需要用 nginx 代理到 80 端口。在/etc/nginx/conf.d新建一个my-project.conf(文件命名随意哈，一般可以用网站域名.conf)：server { listen 80; server_name your-domain.com; location / { proxy_pass http://localhost:3000; } }这是最简单的一个配置了，可以根据实际情况加一些参数哈。做完以上的步骤就完成了一个 node 项目的部署啦。前端负载均衡部署相信很少同学可以接触到负载均衡的部署了，当一个项目的访问量已经大到需要使用负载均衡的时候，一般都会有专门的运维同学去搞了。负载均衡说白了就是将大量的并发请求分担到多个服务器上。负载均衡的架构有很多种，项目的架构是一个不断演进的过程，采用哪种负载均衡的架构需要具体问题具体分析，所以本文不会讲什么时候适合用哪种架构（笔者也不会，笑），接下来将会分享实战如何用 Nginx 从零搭建一个经典的负载均衡架构案例。Nginx Server是直接暴露在最前端的机器，当用户发起请求，首先到达的是Nginx服务器，然后Nginx服务器再将请求通过某种算法分发到各个二级服务器上(图中的Centos2,Centos3,Centos4)，此时Nginx Server充当的就是一个负载均衡的机器(Load Balancer)。笔者手上没有这么多的服务器，为了更完整地演示，所以现在借助 VirtualBox 建立四个虚拟机来模拟四个服务器（当然条件确实限制时可以用同一个服务器的四个端口来代替）。笔者新建了四个 Centos8 系统的虚拟机，用以假设 4 台服务器，对外都有独立 ip(假设分别是 192.168.0.1,2,3,4)。如前面的架构图所示，Centos1 将会是作为Nginx Server，充当最前端的负载均衡服务器，而其余的Centos2,Centos3,Centos4作为应用服务器，为用户提供真正的服务。接下来咱们一步一步去搭建这个系统。一、应用服务器搭建服务站点万丈高楼平地起，咱们首先得先搭建一个能对外的服务，这个服务可以是一个网站也可以是一个接口。为了简单起见，我们就直接起一个koa的Hello World，同时为了后面验证负载均衡的效果，每台机器上部署的代码都稍微改一下文案，如：Hello Centos2,Hello Centos3,Hello Centos4，这样方便后面验证用户的请求是被分发到了哪一台服务器。koa 的 demo 站点已经为大家准备好了：koa-loadbalance。我们这里以Centos2(192.168.0.2)（ip 是虚构的）这台虚拟机为例，将会用pm2部署 koa 站点在该虚拟机上。通过 scp 或 rsync 命令将源码上传到 Centos2 服务器还记得上面的deploy.sh脚本吗？如果你添加了脚本在项目中，就可以npm run deploy直接部署到服务器上了。demo 源码中有这个脚本，大家可以改一下里面实际的 ip，再执行命令哈。ssh 进入 Centos2 服务器ssh root@192.168.0.2安装 node 环境curl -sL https://rpm.nodesource.com/setup_13.x | sudo bash - sudo yum install nodejs可以在这里看当前 node 有哪些版本，选最新的就行，现在是 13。安装 pm2npm i pm2 -gpm2 启动站点在项目根目录执行：pm2 start pm2.jsonpm2 list检查一下项目启动情况 ，同时用curl localhost:3000看返回值：同理，按上面步骤给Centos3和Centos4服务器都将服务部署起来。（记得改一下index.js中的Hello XXX方便后面验证）。不出意外的话，我们的网站就分别运行在三台服务器的 3000 端口了:192.168.0.2:3000 ==> Hello Centos2192.168.0.3:3000 ==> Hello Centos3192.168.0.4:3000 ==> Hello Centos4有同学可能会问，为什么 Centos2,Centos3，Centos4 不用装 Nginx 的？在实际操作中，应用服务器其实是不用暴露在公网上的，它们与负载均衡服务器只需通过内网直接连接就可以了，这样更安全；而我们的站点又是 Node 项目，本身就可以提供 Web 服务，所以不用再装一个 Nginx 进行代理或转发了。二、搭建 Nginx Servernginx 的安装方法：Nginx Install。在Centos1安装好 nginx 就可以了。三、实现负载均衡一开始还没了解过负载均衡时可能会觉得很难完全不知道是怎么配的，然后接下来你会发现超级简单，因为只需要 nginx 一个配置就可以了：upstream。集群所有节点我们将上面已经部署好的Centos2,Centos3,Centos4集群起来，nginx 配置类似下面这样：upstream APPNAME { server host1:port; server host2:port; }APPNAME可以自定义，一般是项目名。在/etc/nginx/conf.d新建一个upstream.conf：upstream koa-loadbalance { server 192.168.0.2:3000; server 192.168.0.3:3000; server 192.168.0.4:3000; }这样，我们已经将三台服务器集成为了http://koa-loadbalance的一个集群，下一步会使用它。配置对外的站点接下来是配置一个面向用户的网站了，我们假设网站会使用www.a.com这个域名，在/etc/nginx/conf.d下新建a.com.conf:server { listen 80; server_name www.a.com; charset utf-8; location / { proxy_pass http://koa-loadbalance; # 这里是上面集群的名称 } }配置结束后记得nginx -s reload重启一下，这样就完成负载均衡的配置了。四、测试如果你的域名是真实的且已经解析到 nginx 服务器，则此时可以直接通过域名访问了。由于笔者这里用的是虚拟机，公网不可访问，所以这里配置一下宿主机的 host，使得www.a.com指向centos1服务器，然后在浏览器打开www.a.com就可以测试我们的负载均衡网站啦。Mac 系统上是sudo vi /etc/hosts，在最后面添加一行：# IP是Centos1的ip 192.168.0.1 www.a.com我们在浏览器访问www.a.com，可以看到随着不断刷新，服务器返回了不同的Hello CentosX，说明我们的请求被分发到三台服务器上了，负载均衡的配置生效啦。五、负载均衡的几种策略nginx 的 upstream 可以设置很多种负载均衡的策略，以下介绍几个常用的策略。轮询（默认）：每个请求按时间顺序逐一分配到不同的后端服务器，如果后端服务器 down 掉，能自动剔除。upstream test { server 192.168.0.2:3000; server 192.168.0.3:3000; server 192.168.0.4:3000; }指定权重 weight:指定轮询几率，weight 和访问比率成正比，用于后端服务器性能不均的情况。upstream test { server 192.168.0.2:3000 weight=5; server 192.168.0.3:3000 weight=10; server 192.168.0.4:3000 weight=20; }ip_hash:每个请求按访问 ip 的 hash 结果分配，这样每个访客固定访问一个后端服务器，可以解决 session 的问题。upstream test { ip_hash; server 192.168.0.2:3000; server 192.168.0.3:3000; server 192.168.0.4:3000; }fair（第三方）:按后端服务器的响应时间来分配请求，响应时间短的优先分配。upstream test { server 192.168.0.2:3000; server 192.168.0.3:3000; server 192.168.0.4:3000; fair; }url_hash（第三方）：按访问 url 的 hash 结果来分配请求，使每个 url 定向到同一个（对应的）后端服务器，后端服务器为缓存时比较有效。upstream test { server 192.168.0.2:3000; server 192.168.0.3:3000; server 192.168.0.4:3000; hash $request_uri; hash_method crc32; }更多的策略请参考：ngx_http_upstream_module，根据实际情况使用上面的这些策略，没有特别需求就使用默认的轮询方式也可以。最后从静态站点到 node 站点，再到负载均衡，相信看完本文大家对整个前端的部署体系都有了一个比较全面的了解。特别是负载均衡，平时接触得少总觉得特别复杂，其实看完了会觉得很简单。更高级一些的部署可能会用上 Docker 或 k8s 的集群了，这个就留待后面再说啦。对于部署方式的提效，本文也分享了一个使用rsync命令的脚步，配合package.json的 script，可以做到一个命令就完成部署的动作。当然，该做法也还是有很大的优化空间的，真正好用的部署方式应该是持续集成，通过 Jenkins 或其他工具实现自动化部署，代码 push 上去就自动构建和部署了。如果你的公司还在用最原始的部署方式，不妨加把劲多探索一些这些更爽更溜的操作啦。
最近在学docker，先拿自己的博客来开下刀[手动狗头]。安装docker我是根据这个教程来安装的：Centos安装Docker。步骤如下：卸载旧版本sudo yum remove docker \ docker-client \ docker-client-latest \ docker-common \ docker-latest \ docker-latest-logrotate \ docker-logrotate \ docker-selinux \ docker-engine-selinux \ docker-engine安装依赖包sudo yum install -y yum-utils \ device-mapper-persistent-data \ lvm2添加yum源(建议使用国内镜像)sudo yum-config-manager \ --add-repo \ https://mirrors.ustc.edu.cn/docker-ce/linux/centos/docker-ce.repo更新软件源缓存并安装docker-cesudo yum makecache fast sudo yum install docker-cesudo yum install docker-ce这一步可能会报错：containerd.io (>= 1.2.2-3)，此时需要先安装新版本的containerd.ioyum install -y https://download.docker.com/linux/centos/7/x86_64/stable/Packages/containerd.io-1.2.6-3.3.el7.x86_64.rpm最新的containerd.io版本请在https://download.docker.com/linux/centos/7/x86_64/stable/Packages/找（国内直接安装containerd.io可能会非常慢导致安装失败，建议可以想办法将rpm包下载到本机再安装）。安装完containerd.io后再sudo yum install docker-ce即可。启动Docker CEsudo systemctl enable docker sudo systemctl start docker测试Docker是否安装正确docker run hello-world配置国内镜像加速vi /etc/docker/daemon.json（该文件不存在时请新建）{ "registry-mirrors": [ "https://dockerhub.azk8s.cn", "https://reg-mirror.qiniu.com" ] }保存后重启服务sudo systemctl daemon-reload sudo systemctl restart docker安装mariadbmkdir -p /data/mariadb docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=test --mount type=bind,source=/data/mariadb,target=/var/lib/mysql --restart=always --name mariadb mariadb--mount命令也可以用-v /data/mariadb:/var/lib/mysql --name mariadb mariadb代替，官方更推荐使用--mount,详情请看官网。参数说明：-d 代表 daemon，即后台运行-p是映射容器的3306端口到宿主机的3306端口，规则是：-p IP:host_port:container_port-e是设置mariadb的密码--mount是让容器的/var/lib/mysql映射到宿主机的/data/mariadb目录中--restart=always是为了在docker重启时，容器能够自动启动PS: 后面笔者会将所有容器依赖的一些数据都放在/data目录下，包括数据库、网站源码、nginx的conf等，目的是为了以后迁移方便，直接将/data拷贝到新服务器就可以。安装nginxmkdir -p /data/nginx/conf.d docker run -p 80:80 -p 443:443 --mount type=bind,source=/data/nginx/conf.d,target=/etc/nginx/conf.d --mount type=bind,source=/data/solution,target=/data/solution --restart=always -d --name nginx nginx说明：--mount type=bind,source=/data/nginx/conf.d,target=/etc/nginx/conf.d是为了将nginx的配置目录映射到宿主机的目录中，这样当nginx容器被销毁时，依然能保留配置。/data/solution用于存放网站项目，也是为了数据与容器分离。-d这里同时映射了80和443端口安装php可以在php的官方镜像源找到最新版本的php，在实际使用中，我们可能还需要装一些php的扩展，而官方源中支持已经帮我们安装了一些扩展的php镜像，如:php:<version>-fpm,其中的<version>指的是php版本，具体可以从官方镜像源找到，当前最高版本是7.4。docker run --name php-fpm -p 9000:9000 --mount type=bind,source=/data/solution,target=/data/solution --restart=always -d php:7.4-fpm这里也将网站根目录/data/solution映射到php容器中，为了php能正确读取nginx中的root配置项。由于typecho需要用到pdo_mysql扩展，因此要在php-fpm上安装这个扩展。# 进入到`php-fpm`容器内部 docker exec -it php-fpm bash # 安装扩展 docker-php-ext-install pdo_mysql # 查看是否已经成功安装 php -m # 退出容器 exit容器与容器通信现在机器上已经运行着nginx、PHP、mariadb三个服务了，他们分别跑在宿主机的80(443)、9000、3306端口上。我们现在需要做到的是让nginx能够使用php的服务，php能够调用mariadb的服务，但容器间默认是互相隔离的，没法直接通信，因此需要想办法让他们能够互相通信。容器间的通信初次接触会有点复杂，所以在这里先和大家分享下一些要点。通过了解，容器间通信主要会有以下几种方法：使用默认的bridge网络，用网桥给容器分配的ip进行通信，官方不推荐用于生产环境。自定义bridge网络，可以通过容器名连接，官方推荐。使用host网络共用宿主机的网络。很多早期的文章分享都会使用--link参数来指定容器通信，但这种方法已经是过时的了，官方不再推荐使用，所以咱们这里也就不再用了，用官方推荐的方法更好。使用默认的bridge网络启动容器时，docker默认会将自动将容器绑定到默认的bridge网络中。打印一下默认的网络：$ docker network ls NETWORK ID NAME DRIVER SCOPE 17e324f45964 bridge bridge local 6ed54d316334 host host local 7092879f2cc8 none null local然后再看一下都有哪些容器连接到了bridge网络：docker network inspect bridge可以看到mariadb,nginx,php-fpm的在bridge中的ip分别是172.17.0.4,172.17.0.2,172.17.0.3，宿主机的ip是172.17.0.1。我们可以通过在容器中通过宿主机的ip来访问对应的服务，即php-fpm想要访问mariadb，可以在php-fpm容器中通过172.17.0.1:3306来访问。这种方式只能使用ip来访问对应的容器的服务，而ip可能会变化的，因此是不推荐使用在生产环境的，所以我们不会使用这种方式。使用自定义bridge网络除了默认的bridge网络，官方推荐用户自定义一个bridge网络用作生产环境，用户自定义的bridge网络不仅支持ip访问，还支持直接使用容器名称进行访问，官方推荐使用在生产环境，因此我们会使用这种方式进行容器间的通信。创建一个自定义网络，名字可以随意，如typecho。docker network create typecho打印一下当前的network，可以看到typecho已经存在了。$ docker network ls NETWORK ID NAME DRIVER SCOPE 429eabf557a3 bridge bridge local 3f47e77c0ded host host local 3784484fb92e typecho bridge local 7ff63bc6d9bd none null local容器使用自定义的网络如果容器尚未创建，可以在docker run命令时通过--network参数来指定网络，如docker run -d --name mynginx --network typecho nginx如果容器已经在运行了，我们也可以通过docker network connect ${网络名} ${容器名}来指定，在上面我们已经运行了mariadb,php-fpm,nginx，所以现在依次为他们都绑定到typecho网络中。docker network connect typecho mariadb docker network connect typecho php-fpm docker network connect typecho nginx检查一下绑定到typecho网络的容器$ docker network inspect typecho [ { "Name": "typecho", "Id": "3784484fb92e36c1448d2303af1b8bdce680e2cba0452fca354cefb6cb81bb54", "Created": "2019-12-08T08:32:57.299750734-05:00", "Scope": "local", "Driver": "bridge", "EnableIPv6": false, "IPAM": { "Driver": "default", "Options": {}, "Config": [ { "Subnet": "172.18.0.0/16", "Gateway": "172.18.0.1" } ] }, "Internal": false, "Attachable": false, "Ingress": false, "ConfigFrom": { "Network": "" }, "ConfigOnly": false, "Containers": { "6c644a890f3d1e7cc9e846236b6467ac31084470289fba5f6af1c2c1a0171e8e": { "Name": "mariadb", "EndpointID": "881c4f00a8d15f4e941d50faf53846a3ab8f15772fccc5e8c568e8d97b346b3b", "MacAddress": "02:42:ac:12:00:02", "IPv4Address": "172.18.0.2/16", "IPv6Address": "" }, "f98355c7b220c0ab2897aa2478037e3322d78ccd81518af7a4756b07e1e26719": { "Name": "nginx", "EndpointID": "f14e5c36fcfe7ac5f26b8495335366957b6d7ab6d59c13e55cae2f1dc7751929", "MacAddress": "02:42:ac:12:00:04", "IPv4Address": "172.18.0.4/16", "IPv6Address": "" }, "f9cb6cfcebf6f82beca76d3061765c1deb482a9c3c30d0c6d3644fe10ae40e3e": { "Name": "php-fpm", "EndpointID": "c4a2e26fe887e04f01a36eea9771eb2e817ef29d1bd7c435b380a7ca18485e64", "MacAddress": "02:42:ac:12:00:03", "IPv4Address": "172.18.0.3/16", "IPv6Address": "" } }, "Options": {}, "Labels": {} } ]可以看到mariadb,php-fpm,nginx都已经连接到typecho网络了。这样，我们在nginx容器，就可以直接通过php-fpm的名字来调用php的服务了。使用host网络如果容器使用了host网络，即docker运行时通过--network host来指定容器的网络，会使得容器共享宿主机的网络配置，即容器的localhost就是宿主机的localhost。直接使用Docker host网络最大的好处就是性能更好，如果容器对网络传输效率有较高要求，则可以选择host网络。当然不便之处就是牺牲一些灵活性，比如要考虑端口冲突问题，Docker host上已经使用的端口就不能再用了。我们这里暂时不使用这种方式。容器内与宿主机通信如果在容器内部想要访问宿主机的端口，是不行的，两者的环境是隔离的。如nginx容器的配置中想要使用http://localhost:1234是访问不到宿主机的1234端口的。办法一：使用宿主机ip$ ip addr show docker0 3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:d5:4c:f2:1e brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::42:d5ff:fe4c:f21e/64 scope link valid_lft forever preferred_lft forever可以看到宿主机的ip是172.17.0.1，容器内通过该ip访问宿主机。办法二：容器使用host网络在容器启动时指定连接到host网络，容器暴露的端口即宿主机暴露的端口，从而做到容器内也可以通过localhost访问宿主机。docker run -d --name nginx --network host nginx参考该文章： Docker容器访问宿主机网络迁移typecho博客迁移数据库由于是第一次迁移，新旧两个数据库的配置可能会有些不一样，所以为了不改变新服务器的一些配置，我们只迁移mariadb中涉及到数据的部分。具体操作就是： 将原服务器/var/lib/mysql中数据库相关的文件夹提取出来，如旧服务器有两个名为typecho和test的数据库，就只将这两个文件夹复制到新服务器的/data/mariadb中，然后重启一下mariadb即可：docker restart mariadb。PS: 还有一种迁移办法是可以选择将旧服务器的数据库备份成sql文件，然后在新服务器做还原哦。迁移typecho源码笔者这里将typecho的源代码放到/data/solution/typecho文件夹中。配置nginxvi /data/nginx/conf.d/typecho.confserver { listen 80; server_name localhost; location / { try_files $uri $uri/ /index.php?$args; } location ~ .php$ { root /data/solution/typecho; fastcgi_pass php-fpm:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }server_name我这里写了localhost，暂时算是本地测试一下，实际使用时会改成域名。修改nginxi配置后需要重启一下nginx的镜像才能生效：docker restart nginx。typecho的配置文件config.inc.php注意数据库连接的配置，数据库的host可以直接使用mariadb，因为前面将两个容器绑在了同一个网络，php能正常解析到。/** .... */ /** 定义数据库参数 */ $db = new Typecho_Db('Pdo_Mysql', 't_'); $db->addServer(array ( 'host' => 'mariadb', 'user' => 'root', 'password' => 'YOUR_PASSWORD', 'charset' => 'utf8', 'port' => '3306', 'database' => 'typecho', ), Typecho_Db::READ | Typecho_Db::WRITE); Typecho_Db::set($db);至此，整个迁移过程就完成了，可以用curl localhost测试一下或在浏览器中直接使用服务器的ip来访问页面啦。PS: 记得不要忘了要打开服务器的80端口防火墙哦~~Tips: 如果没法正常启动typecho，可能是某一步配置的不对，可以通过docker logs <container_name>命令查看对应容器的日志，然后查找相关的解决办法哦。附上两篇typecho解决错误的文章：Typecho 部署踩坑, 安装Typecho卡在“确认您的配置,数据库配置”问题的终极解决方法https证书迁移(202011更新)证书涉及到自动更新，比较复杂，请看： docker安装acme.sh。总结用上docker最爽的一点就是不用再关心怎么安装软件的过程了，整个过程十分清爽。在整个过程中有几点可以留意一下：笔者将所有docker依赖的数据都挂载到宿主机的/data目录中，是为了方便管理和以后迁移；每个服务都是一个独立的容器，不会互相影响。如mariadb除了可以为我的博客系统服务，也可以给其他的服务调用；nginx也支持多站点，只需要/data/nginx/conf.d增加一个配置。由于是第一次用上docker迁移，第一次会有点折腾，但以后再迁移就会方便多了。整个流程大概会是这样子：# 在新服务器拉取旧服务器的数据 scp -r root@<old_server_ip>:/data /data/ # 安装docker-ce # 新建一个网络 docker network create typecho # 分别启动mariadb，php-fpm，nginx三个容器，并绑定typecho网络 docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=test --mount type=bind,source=/data/mariadb,target=/var/lib/mysql --restart=always --network typecho --name mariadb mariadb docker run --name php-fpm -p 9000:9000 --mount type=bind,source=/data/solution,target=/data/solution --restart=always --network typecho -d php:7.4-fpm docker run -p 80:80 -p 443:443 --mount type=bind,source=/data/nginx/conf.d,target=/etc/nginx/conf.d --mount type=bind,source=/data/solution,target=/data/solution --restart=always --network typecho -d --name nginx nginx # 安装php扩展 docker exec -i php-fpm docker-php-ext-install pdo_mysql # 测试走起 curl localhost
前言前段时间写了篇文章《axios如何利用promise无痛刷新token》，陆陆续续收到一些反馈。发现不少同学会想要从在请求前拦截的思路入手，甚至收到了几个邮件来询问博主遇到的问题，所以索性再写一篇文章来说说另一个思路的实现和注意的地方。过程会稍微啰嗦，不想看实现过程的同学可以直接拉到最后面看最终代码。PS：在本文就略过一些前提条件了，请新同学阅读本文前先看一下前一篇文章《axios如何利用promise无痛刷新token》。前提条件前端登录后，后端返回token和token有效时间段tokenExprieIn，当token过期时间到了，前端需要主动用旧token去获取一个新的token，做到用户无感知地去刷新token。PS: tokenExprieIn是一个单位为秒的时间段，不建议使用绝对时间，绝对时间可能会由于本地和服务器时区不一样导致出现问题。实现思路方法一在请求发起前拦截每个请求，判断token的有效时间是否已经过期，若已过期，则将请求挂起，先刷新token后再继续请求。方法二不在请求前拦截，而是拦截返回后的数据。先发起请求，接口返回过期后，先刷新token，再进行一次重试。前文已经实现了方法二，本文会从头实现一下方法一。实现基本骨架在请求前进行拦截，我们主要会使用axios.interceptors.request.use()这个方法。照例先封装个request.js的基本骨架：import axios from 'axios' // 从localStorage中获取token，token存的是object信息，有tokenExpireTime和token两个字段 function getToken () { let tokenObj = {} try { tokenObj = storage.get('token') tokenObj = tokenObj ? JSON.parse(tokenObj) : {} } catch { console.error('get token from localStorage error') } return tokenObj } // 给实例添加一个setToken方法，用于登录后方便将最新token动态添加到header，同时将token保存在localStorage中 instance.setToken = (obj) => { instance.defaults.headers['X-Token'] = obj.token window.localStorage.setItem('token', JSON.stringify(obj)) // 注意这里需要变成字符串后才能放到localStorage中 } // 创建一个axios实例 const instance = axios.create({ baseURL: '/api', timeout: 300000, headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }) // 请求发起前拦截 instance.interceptors.request.use((config) => { const tokenObj = getToken() // 为每个请求添加token请求头 config.headers['X-Token'] = tokenObj.token // **接下来主要拦截的实现就在这里** return config }, (error) => { // Do something with request error return Promise.reject(error) }) // 请求返回后拦截 instance.interceptors.response.use(response => { const { code } = response.data if (code === 1234) { // token过期了，直接跳转到登录页 window.location.href = '/' } return response }, error => { console.log('catch', error) return Promise.reject(error) }) export default instance与前文略微不同的是，由于方法二不需要用到过期时间，所以前文localStorage中只存了token一个字符串，而方法一这里需要用到过期时间了，所以得存多一个数据，因此localStorage中存的是Object类型的数据，从localStorage中取值出来需要JSON.parse一下，为了防止发生错误所以尽量使用try...catch。axios.interceptors.request.use()实现首先不需要想得太复杂，先不考虑多个请求同时进来的情况，咱从最常见的场景入手：从localStorage拿到上一次存储的过期时间，判断是否已经到了过期时间，是就立即刷新token然后再发起请求。function refreshToken () { // instance是当前request.js中已创建的axios实例 return instance.post('/refreshtoken').then(res => res.data) } instance.interceptors.request.use((config) => { const tokenObj = getToken() // 为每个请求添加token请求头 config.headers['X-Token'] = tokenObj.token if (tokenObj.token && tokenObj.tokenExpireTime) { const now = Date.now() if (now >= tokenObj.tokenExpireTime) { // 当前时间大于过期时间，说明已经过期了，返回一个Promise，执行refreshToken后再return当前的config return refreshToken().then(res => { const { token, tokenExprieIn } = res.data const tokenExpireTime = now + tokenExprieIn * 1000 instance.setToken({ token, tokenExpireTime }) // 存token到localStorage console.log('刷新成功, return config即是恢复当前请求') config.headers['X-Token'] = token // 将最新的token放到请求头 return config }).catch(res => { console.error('refresh token error: ', res) }) } } return config }, (error) => { // Do something with request error return Promise.reject(error) })这里有两个需要注意的地方：之前说到登录或刷新token的接口返回的是一个单位为秒的时间段tokenExpireIn，而我们存到localStorage中的是已经是一个基于当前时间和有效时间段算出的最终时间tokenExpireTime，是一个绝对时间，比如当前时间是12点，有效时间是3600秒（1个小时），则存到localStorage的过期时间是13点的时间戳，这样可以少存一个当前时间的字段到localStorage中，使用时只需要判断该绝对时间即可。instance.interceptors.request.use中返回一个Promise，就可以使得该请求是先执行refreshToken后再return config的，才能保证先刷新token后再真正发起请求。其实博主直接运行上面代码后发现了一个严重错误，进入了一个死循环。这是因为博主没有注意到一个问题：axios.interceptors.request.use()会拦截所有使用该实例发起的请求，即执行refreshToken()时又一次进入了axios.interceptors.request.use()，导致一直在return refreshToken()。因此需要将刷新token和登录这两种情况排除出去，登录和刷新token都不需要判断是否过期的拦截，我们可以通过config.url来判断是哪个接口：instance.interceptors.request.use((config) => { const tokenObj = getToken() // 为每个请求添加token请求头 config.headers['X-Token'] = tokenObj.token // 登录接口和刷新token接口绕过 if (config.url.indexOf('/refreshToken') >= 0 || config.url.indexOf('/login') >= 0) { return config } if (tokenObj.token && tokenObj.tokenExpireTime) { const now = Date.now() if (now >= tokenObj.tokenExpireTime) { // 当前时间大于过期时间，说明已经过期了，返回一个Promise，执行refreshToken后再return当前的config return refreshToken().then(res => { const { token, tokenExprieIn } = res.data const tokenExpireTime = now + tokenExprieIn * 1000 instance.setToken({ token, tokenExpireTime }) // 存token到localStorage console.log('刷新成功, return config即是恢复当前请求') config.headers['X-Token'] = token // 将最新的token放到请求头 return config }).catch(res => { console.error('refresh token error: ', res) }) } } return config }, (error) => { // Do something with request error return Promise.reject(error) })问题和优化接下来就是要考虑复杂一点的问题了防止多次刷新token当几乎同时进来两个请求，为了避免多次执行refreshToken，需要引入一个isRefreshing的进行标记：let isRefreshing = false instance.interceptors.request.use((config) => { const tokenObj = getToken() // 为每个请求添加token请求头 config.headers['X-Token'] = tokenObj.token // 登录接口和刷新token接口绕过 if (config.url.indexOf('/refreshToken') >= 0 || config.url.indexOf('/login') >= 0) { return config } if (tokenObj.token && tokenObj.tokenExpireTime) { const now = Date.now() if (now >= tokenObj.tokenExpireTime) { if (!isRefreshing) { isRefreshing = true return refreshToken().then(res => { const { token, tokenExprieIn } = res.data const tokenExpireTime = now + tokenExprieIn * 1000 instance.setToken({ token, tokenExpireTime }) // 存token到localStorage isRefreshing = false //刷新成功，恢复标志位 config.headers['X-Token'] = token // 将最新的token放到请求头 return config }).catch(res => { console.error('refresh token error: ', res) }) } } } return config }, (error) => { // Do something with request error return Promise.reject(error) })多个请求时存到队列中等刷新token后再发起我们已经知道了当前已经过期或者正在刷新token，此时再有请求发起，就应该让后面的这些请求等一等，等到refreshToken结束后再真正发起，所以需要用到一个Promise来让它一直等。而后面的所有请求，我们将它们存放到一个requests的队列中，等刷新token后再依次resolve。instance.interceptors.request.use((config) => { const tokenObj = getToken() // 添加请求头 config.headers['X-Token'] = tokenObj.token // 登录接口和刷新token接口绕过 if (config.url.indexOf('/refreshToken') >= 0 || config.url.indexOf('/login') >= 0) { return config } if (tokenObj.token && tokenObj.tokenExpireTime) { const now = Date.now() if (now >= tokenObj.tokenExpireTime) { // 立即刷新token if (!isRefreshing) { console.log('刷新token ing') isRefreshing = true refreshToken().then(res => { const { token, tokenExprieIn } = res.data const tokenExpireTime = now + tokenExprieIn * 1000 instance.setToken({ token, tokenExpireTime }) isRefreshing = false return token }).then((token) => { console.log('刷新token成功，执行队列') requests.forEach(cb => cb(token)) // 执行完成后，清空队列 requests = [] }).catch(res => { console.error('refresh token error: ', res) }) } const retryOriginalRequest = new Promise((resolve) => { requests.push((token) => { // 因为config中的token是旧的，所以刷新token后要将新token传进来 config.headers['X-Token'] = token resolve(config) }) }) return retryOriginalRequest } } return config }, (error) => { // Do something with request error return Promise.reject(error) })这里做了一点改动，注意到refreshToken()这一句前面去掉了return，而是改为了在后面return retryOriginalRequest，即当发现有请求是过期的就存进requests数组，等refreshToken结束后再执行requests队列，这是为了不影响原来的请求执行次序。我们假设同时有请求1，请求2，请求3依次同时进来，我们希望是请求1发现过期，refreshToken后再依次执行请求1，请求2，请求3。按之前return refreshToken()的写法，会大概写成这样 if (tokenObj.token && tokenObj.tokenExpireTime) { const now = Date.now() if (now >= tokenObj.tokenExpireTime) { // 立即刷新token if (!isRefreshing) { console.log('刷新token ing') isRefreshing = true return refreshToken().then(res => { const { token, tokenExprieIn } = res.data const tokenExpireTime = now + tokenExprieIn * 1000 instance.setToken({ token, tokenExpireTime }) isRefreshing = false config.headers['X-Token'] = token return config // 请求1 }).catch(res => { console.error('refresh token error: ', res) }).finally(() => { console.log('执行队列') requests.forEach(cb => cb(token)) // 执行完成后，清空队列 requests = [] }) } else { // 只有请求2和请求3能进入队列 const retryOriginalRequest = new Promise((resolve) => { requests.push((token) => { config.headers['X-Token'] = token resolve(config) }) }) return retryOriginalRequest } } } return config队列里面只有请求2和请求3，代码看起来应该是return了请求1后，再在finally执行队列的，但实际的执行顺序会变成请求2，请求3，请求1，即请求1变成了最后一个执行的，会改变执行顺序。所以博主换了个思路，无论是哪个请求进入了过期流程，我们都将请求放到队列中，都return一个未resolve的Promise，等刷新token结束后再一一清算，这样就可以保证请求1，请求2，请求3这样按原来顺序执行了。这里多说一句，可能很多刚接触前端的同学无法理解requests.forEach(cb => cb(token))是如何执行的。// 我们先看一下，定义fn1 function fn1 () { console.log('执行fn1') } // 执行fn1,只需后面加个括号 fn1() // 回归到我们request数组中，每一项其实存的就是一个类似fn1的一个函数 const fn2 = (token) => { config.headers['X-Token'] = token resolve(config) } // 我们要执行fn2，也只需在后面加个括号就可以了 fn2() // 由于requests是一个数组，所以我们想遍历执行里面的所有的项，所以用上了forEach requests.forEach(fn => { // 执行fn fn() })最后完整代码import axios from 'axios' // 从localStorage中获取token，token存的是object信息，有tokenExpireTime和token两个字段 function getToken () { let tokenObj = {} try { tokenObj = storage.get('token') tokenObj = tokenObj ? JSON.parse(tokenObj) : {} } catch { console.error('get token from localStorage error') } return tokenObj } function refreshToken () { // instance是当前request.js中已创建的axios实例 return instance.post('/refreshtoken').then(res => res.data) } // 给实例添加一个setToken方法，用于登录后方便将最新token动态添加到header，同时将token保存在localStorage中 instance.setToken = (obj) => { instance.defaults.headers['X-Token'] = obj.token window.localStorage.setItem('token', JSON.stringify(obj)) // 注意这里需要变成字符串后才能放到localStorage中 } instance.interceptors.request.use((config) => { const tokenObj = getToken() // 添加请求头 config.headers['X-Token'] = tokenObj.token // 登录接口和刷新token接口绕过 if (config.url.indexOf('/rereshToken') >= 0 || config.url.indexOf('/login') >= 0) { return config } if (tokenObj.token && tokenObj.tokenExpireTime) { const now = Date.now() if (now >= tokenObj.tokenExpireTime) { // 立即刷新token if (!isRefreshing) { console.log('刷新token ing') isRefreshing = true refreshToken().then(res => { const { token, tokenExprieIn } = res.data const tokenExpireTime = now + tokenExprieIn * 1000 instance.setToken({ token, tokenExpireTime }) isRefreshing = false return token }).then((token) => { console.log('刷新token成功，执行队列') requests.forEach(cb => cb(token)) // 执行完成后，清空队列 requests = [] }).catch(res => { console.error('refresh token error: ', res) }) } const retryOriginalRequest = new Promise((resolve) => { requests.push((token) => { // 因为config中的token是旧的，所以刷新token后要将新token传进来 config.headers['X-Token'] = token resolve(config) }) }) return retryOriginalRequest } } return config }, (error) => { // Do something with request error return Promise.reject(error) }) // 请求返回后拦截 instance.interceptors.response.use(response => { const { code } = response.data if (code === 1234) { // token过期了，直接跳转到登录页 window.location.href = '/' } return response }, error => { console.log('catch', error) return Promise.reject(error) }) export default instance建议一步步调试的同学，可以先去掉window.location.href = '/'这个跳转，保留log方便调试。感谢看到最后，感谢点赞^_^。
需求最近遇到个需求：前端登录后，后端返回token和token有效时间，当token过期时要求用旧token去获取新的token，前端需要做到无痛刷新token，即请求刷新token时要做到用户无感知。需求解析当用户发起一个请求时，判断token是否已过期，若已过期则先调refreshToken接口，拿到新的token后再继续执行之前的请求。这个问题的难点在于：当同时发起多个请求，而刷新token的接口还没返回，此时其他请求该如何处理？接下来会循序渐进地分享一下整个过程。实现思路由于后端返回了token的有效时间，可以有两种方法：方法一：在请求发起前拦截每个请求，判断token的有效时间是否已经过期，若已过期，则将请求挂起，先刷新token后再继续请求。方法二：不在请求前拦截，而是拦截返回后的数据。先发起请求，接口返回过期后，先刷新token，再进行一次重试。两种方法对比方法一优点： 在请求前拦截，能节省请求，省流量。缺点： 需要后端额外提供一个token过期时间的字段；使用了本地时间判断，若本地时间被篡改，特别是本地时间比服务器时间慢时，拦截会失败。PS：token有效时间建议是时间段，类似缓存的MaxAge，而不要是绝对时间。当服务器和本地时间不一致时，绝对时间会有问题。方法二优点：不需额外的token过期字段，不需判断时间。缺点： 会消耗多一次请求，耗流量。综上，方法一和二优缺点是互补的，方法一有校验失败的风险（本地时间被篡改时，当然一般没有用户闲的蛋疼去改本地时间的啦），方法二更简单粗暴，等知道服务器已经过期了再重试一次，只是会耗多一个请求。在这里博主选择了 方法二。实现这里会使用axios来实现，方法一是请求前拦截，所以会使用axios.interceptors.request.use()这个方法；而方法二是请求后拦截，所以会使用axios.interceptors.response.use()方法。封装axios基本骨架首先说明一下，项目中的token是存在localStorage中的。request.js基本骨架:import axios from 'axios' // 从localStorage中获取token function getLocalToken () { const token = window.localStorage.getItem('token') return token } // 给实例添加一个setToken方法，用于登录后将最新token动态添加到header，同时将token保存在localStorage中 instance.setToken = (token) => { instance.defaults.headers['X-Token'] = token window.localStorage.setItem('token', token) } // 创建一个axios实例 const instance = axios.create({ baseURL: '/api', timeout: 300000, headers: { 'Content-Type': 'application/json', 'X-Token': getLocalToken() // headers塞token } }) // 拦截返回的数据 instance.interceptors.response.use(response => { // 接下来会在这里进行token过期的逻辑处理 return response }, error => { return Promise.reject(error) }) export default instance这个是项目中一般的axios实例的封装，创建实例时，将本地已有的token放进header，然后export出去供调用。接下来就是如何拦截返回的数据啦。instance.interceptors.response.use拦截实现后端接口一般会有一个约定好的数据结构，如：{code: 1234, message: 'token过期', data: {}}如我这里，后端约定当code === 1234时表示token过期了，此时就要求刷新token。instance.interceptors.response.use(response => { const { code } = response.data if (code === 1234) { // 说明token过期了,刷新token return refreshToken().then(res => { // 刷新token成功，将最新的token更新到header中，同时保存在localStorage中 const { token } = res.data instance.setToken(token) // 获取当前失败的请求 const config = response.config // 重置一下配置 config.headers['X-Token'] = token config.baseURL = '' // url已经带上了/api，避免出现/api/api的情况 // 重试当前请求并返回promise return instance(config) }).catch(res => { console.error('refreshtoken error =>', res) //刷新token失败，神仙也救不了了，跳转到首页重新登录吧 window.location.href = '/' }) } return response }, error => { return Promise.reject(error) }) function refreshToken () { // instance是当前request.js中已创建的axios实例 return instance.post('/refreshtoken').then(res => res.data) }这里需要额外注意的是，response.config就是原请求的配置，但这个是已经处理过了的，config.url已经带上了baseUrl，因此重试时需要去掉，同时token也是旧的，需要刷新下。以上就基本做到了无痛刷新token，当token正常时，正常返回，当token已过期，则axios内部进行一次刷新token和重试。对调用者来说，axios内部的刷新token是一个黑盒，是无感知的，因此需求已经做到了。问题和优化上面的代码还是存在一些问题的，没有考虑到多次请求的问题，因此需要进一步优化。如何防止多次刷新token如果refreshToken接口还没返回，此时再有一个过期的请求进来，上面的代码就会再一次执行refreshToken，这就会导致多次执行刷新token的接口，因此需要防止这个问题。我们可以在request.js中用一个flag来标记当前是否正在刷新token的状态，如果正在刷新则不再调用刷新token的接口。// 是否正在刷新的标记 let isRefreshing = false instance.interceptors.response.use(response => { const { code } = response.data if (code === 1234) { if (!isRefreshing) { isRefreshing = true return refreshToken().then(res => { const { token } = res.data instance.setToken(token) const config = response.config config.headers['X-Token'] = token config.baseURL = '' return instance(config) }).catch(res => { console.error('refreshtoken error =>', res) window.location.href = '/' }).finally(() => { isRefreshing = false }) } } return response }, error => { return Promise.reject(error) })这样子就可以避免在刷新token时再进入方法了。但是这种做法是相当于把其他失败的接口给舍弃了，假如同时发起两个请求，且几乎同时返回，第一个请求肯定是进入了refreshToken后再重试，而第二个请求则被丢弃了，仍是返回失败，所以接下来还得解决其他接口的重试问题。同时发起两个或以上的请求时，其他接口如何重试两个接口几乎同时发起和返回，第一个接口会进入刷新token后重试的流程，而第二个接口需要先存起来，然后等刷新token后再重试。同样，如果同时发起三个请求，此时需要缓存后两个接口，等刷新token后再重试。由于接口都是异步的，处理起来会有点麻烦。当第二个过期的请求进来，token正在刷新，我们先将这个请求存到一个数组队列中，想办法让这个请求处于等待中，一直等到刷新token后再逐个重试清空请求队列。那么如何做到让这个请求处于等待中呢？为了解决这个问题，我们得借助Promise。将请求存进队列中后，同时返回一个Promise，让这个Promise一直处于Pending状态（即不调用resolve），此时这个请求就会一直等啊等，只要我们不执行resolve，这个请求就会一直在等待。当刷新请求的接口返回来后，我们再调用resolve，逐个重试。最终代码：// 是否正在刷新的标记 let isRefreshing = false // 重试队列，每一项将是一个待执行的函数形式 let requests = [] instance.interceptors.response.use(response => { const { code } = response.data if (code === 1234) { const config = response.config if (!isRefreshing) { isRefreshing = true return refreshToken().then(res => { const { token } = res.data instance.setToken(token) config.headers['X-Token'] = token config.baseURL = '' // 已经刷新了token，将所有队列中的请求进行重试 requests.forEach(cb => cb(token)) // 重试完了别忘了清空这个队列（掘金评论区同学指点） requests = [] return instance(config) }).catch(res => { console.error('refreshtoken error =>', res) window.location.href = '/' }).finally(() => { isRefreshing = false }) } else { // 正在刷新token，返回一个未执行resolve的promise return new Promise((resolve) => { // 将resolve放进队列，用一个函数形式来保存，等token刷新后直接执行 requests.push((token) => { config.baseURL = '' config.headers['X-Token'] = token resolve(instance(config)) }) }) } } return response }, error => { return Promise.reject(error) })这里可能比较难理解的是requests这个队列中保存的是一个函数，这是为了让resolve不执行，先存起来，等刷新token后更方便调用这个函数使得resolve执行。至此，问题应该都解决了。最后完整代码import axios from 'axios' // 从localStorage中获取token function getLocalToken () { const token = window.localStorage.getItem('token') return token } // 给实例添加一个setToken方法，用于登录后将最新token动态添加到header，同时将token保存在localStorage中 instance.setToken = (token) => { instance.defaults.headers['X-Token'] = token window.localStorage.setItem('token', token) } function refreshToken () { // instance是当前request.js中已创建的axios实例 return instance.post('/refreshtoken').then(res => res.data) } // 创建一个axios实例 const instance = axios.create({ baseURL: '/api', timeout: 300000, headers: { 'Content-Type': 'application/json', 'X-Token': getLocalToken() // headers塞token } }) // 是否正在刷新的标记 let isRefreshing = false // 重试队列，每一项将是一个待执行的函数形式 let requests = [] instance.interceptors.response.use(response => { const { code } = response.data if (code === 1234) { const config = response.config if (!isRefreshing) { isRefreshing = true return refreshToken().then(res => { const { token } = res.data instance.setToken(token) config.headers['X-Token'] = token config.baseURL = '' // 已经刷新了token，将所有队列中的请求进行重试 requests.forEach(cb => cb(token)) requests = [] return instance(config) }).catch(res => { console.error('refreshtoken error =>', res) window.location.href = '/' }).finally(() => { isRefreshing = false }) } else { // 正在刷新token，将返回一个未执行resolve的promise return new Promise((resolve) => { // 将resolve放进队列，用一个函数形式来保存，等token刷新后直接执行 requests.push((token) => { config.baseURL = '' config.headers['X-Token'] = token resolve(instance(config)) }) }) } } return response }, error => { return Promise.reject(error) }) export default instance希望对大家有帮助。感谢看到最后，感谢点赞^_^。后续更新针对方法一的实现，请大家阅读： axios如何利用promise无痛刷新token（二）