微信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
前提给一个纯原生的项目添加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 上去就自动构建和部署了。如果你的公司还在用最原始的部署方式，不妨加把劲多探索一些这些更爽更溜的操作啦。
前言前段时间写了篇文章《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（二）
前段时间刚好公司有项目使用了Nuxt.js来搭建，而刚好在公司内部做了个分享，稍微再整理一下发出来。本文比较适合初用Nuxt.js的同学，主要讲下搭建过程中做的一些配置。建议初次使用Nuxt.js的同学先过一遍官方文档，再回头看下我这篇文章。一、为什么要用Nuxt.js原因其实不用多说，就是利用Nuxt.js的服务端渲染能力来解决Vue项目的SEO问题。二、Nuxt.js和纯Vue项目的简单对比1. build后目标产物不同vue: distnuxt: .nuxt2. 网页渲染流程vue: 客户端渲染，先下载js后，通过ajax来渲染页面；nuxt： 服务端渲染，可以做到服务端拼接好html后直接返回，首屏可以做到无需发起ajax请求；3. 部署流程vue： 只需部署dist目录到服务器，没有服务端，需要用nginx等做Web服务器；nuxt： 需要部署几乎所有文件到服务器（除node_modules，.git），自带服务端，需要pm2管理（部署时需要reload pm2），若要求用域名，则需要nginx做代理。4. 项目入口vue: /src/main.js，在main.js可以做一些全局注册的初始化工作；nuxt: 没有main.js入口文件，项目初始化的操作需要通过nuxt.config.js进行配置指定。三、从零搭建一个Nuxt.js项目并配置新建一个项目直接使用脚手架进行安装：npx create-nuxt-app <项目名>大概选上面这些选项。值得一说的是，关于Choose custom server framework（选择服务端框架），可以根据你的业务情况选择一个服务端框架，常见的就是Express、Koa，默认是None，即Nuxt默认服务器，我这里选了Express。选择默认的Nuxt服务器，不会生成server文件夹，所有服务端渲染的操作都是Nuxt帮你完成，无需关心服务端的细节，开发体验更接近Vue项目，缺点是无法做一些服务端定制化的操作。选择其他的服务端框架，比如Express，会生成server文件夹，帮你搭建一个基本的Node服务端环境，可以在里面做一些node端的操作。比如我公司业务需要（解析protobuf）使用了Express，对真正的服务端api做一层转发，在node端解析protobuf后，返回json数据给客户端。还有Choose Nuxt.js modules（选择nuxt.js的模块），可以选axios和PWA,如果选了axios，则会帮你在nuxt实例下注册$axios，让你可以在.vue文件中直接this.$axios发起请求。开启eslint检查在nuxt.config.js的build属性下添加: build: { extend (config, ctx) { // Run ESLint on save if (ctx.isDev && ctx.isClient) { config.module.rules.push({ enforce: 'pre', test: /\.(js|vue)$/, loader: 'eslint-loader', exclude: /(node_modules)/ }) } } }这样开发时保存文件就可以检查语法了。nuxt默认使用的规则是@nuxtjs（底层来自eslint-config-standard），规则配置在/.eslintrc.js:module.exports = { root: true, env: { browser: true, node: true }, parserOptions: { parser: 'babel-eslint' }, extends: [ '@nuxtjs', // 该规则对应这个依赖： @nuxtjs/eslint-config 'plugin:nuxt/recommended' ], // add your custom rules here rules: { 'nuxt/no-cjs-in-config': 'off' } } 如果不习惯用standard规则的团队可以将@nuxtjs改成其他的。使用dotenv和@nuxtjs/dotenv统一管理环境变量在node端，我们喜欢使用dotenv来管理项目中的环境变量，把所有环境变量都放在根目录下的.env中。安装：npm i dotenv使用：在根目录下新建一个.env文件，并写上需要管理的环境变量，比如服务端地址APIHOST:APIHOST=http://your_server.com/api在/server/index.js中使用（该文件是选Express服务端框架自动生成的）：require('dotenv').config() // 通过process.env即可使用 console.log(process.env.APIHOST) // http://your_server.com/api此时我们只是让服务端可以使用.env的文件而已，Nuxt客户端并不能使用.env，按Nuxt.js文档所说，可以将客户端的环境变量放置在nuxt.config.js中：module.exports = { env: { baseUrl: process.env.BASE_URL || 'http://localhost:3000' } }但如果node端和客户端需要使用同一个环境变量时(后面讲到API鉴权时会使用同一个SECRET变量)，就需要同时在nuxt.config.js和.env维护这个字段，比较麻烦，我们更希望环境变量只需要在一个地方维护，所以为了解决这个问题，我找到了@nuxtjs/dotenv这个依赖，它使得nuxt的客户端也可以直接使用.env，达到了我们的预期。安装：npm i @nuxtjs/dotenv客户端也是通过process.env.XXX来使用，不再举例啦。这样，我们通过dotenv和@nuxtjs/dotenv这两个包，就可以统一管理开发环境中的变量啦。另外，@nuxtjs/dotenv允许打包时指定其他的env文件。比如，开发时我们使用的是.env，但我们打包的线上版本想用其他的环境变量，此时可以指定build时用另一份文件如/.env.prod，只需在nuxt.config.js指定：module.exports = { modules: [ ['@nuxtjs/dotenv', { filename: '.env.prod' }] // 指定打包时使用的dotenv ], }@nuxtjs/toast模块toast可以说是很常用的功能，一般的UI框架都会有这个功能。但如果你的站点没有使用UI框架，而alert又太丑，不妨引入该模块：npm install @nuxtjs/toast然后在nuxt.config.js中引入module.exports = { modules: [ '@nuxtjs/toast', ['@nuxtjs/dotenv', { filename: '.env.prod' }] // 指定打包时使用的dotenv ], toast: {// toast模块的配置 position: 'top-center', duration: 2000 } }这样，nuxt就会在全局注册$toast方法供你使用，非常方便：this.$toast.error('服务器开小差啦~~') this.$toast.error('请求成功~~')API鉴权对于某些敏感的服务，我们可能需要对API进行鉴权，防止被人轻易盗用我们node端的API，因此我们需要做一个API的鉴权机制。常见的方案有jwt，可以参考一下阮老师的介绍：《JSON Web Token 入门教程》。如果场景比较简单，可以自行设计一下，这里提供一个思路：客户端和node端在环境变量中声明一个秘钥：SECRET=xxxx，注意这个是保密的;客户端发起请求时，将当前时间戳(timestamp)和SECRET通过某种算法，生成一个signature，请求时带上timestamp和signature；node接收到请求，获得timestamp和signature，将timestamp和秘钥用同样的算法再生成一次签名_signature对比客户端请求的signature和node用同样的算法生成的_signature，如果一致就表示通过，否则鉴权失败。具体的步骤：客户端对axios进行一层封装：import axios from 'axios' import sha256 from 'crypto-js/sha256' import Base64 from 'crypto-js/enc-base64' // 加密算法，需安装crypto-js function crypto (str) { const _sign = sha256(str) return encodeURIComponent(Base64.stringify(_sign)) } const SECRET = process.env.SECRET const options = { headers: { 'X-Requested-With': 'XMLHttpRequest' }, timeout: 30000, baseURL: '/api' } // The server-side needs a full url to works if (process.server) { options.baseURL = `http://${process.env.HOST || 'localhost'}:${process.env.PORT || 3000}/api` options.withCredentials = true } const instance = axios.create(options) // 对axios的每一个请求都做一个处理，携带上签名和timestamp instance.interceptors.request.use( config => { const timestamp = new Date().getTime() const param = `timestamp=${timestamp}&secret=${SECRET}` const sign = crypto(param) config.params = Object.assign({}, config.params, { timestamp, sign }) return config } ) export default instance接着，在server端写一个鉴权的中间件，/server/middleware/verify.js：const sha256 = require('crypto-js/sha256') const Base64 = require('crypto-js/enc-base64') function crypto (str) { const _sign = sha256(str) return encodeURIComponent(Base64.stringify(_sign)) } // 使用和客户端相同的一个秘钥 const SECRET = process.env.SECRET function verifyMiddleware (req, res, next) { const { sign, timestamp } = req.query // 加密算法与请求时的一致 const _sign = crypto(`timestamp=${timestamp}&secret=${SECRET}`) if (_sign === sign) { next() } else { res.status(401).send({ message: 'invalid token' }) } } module.exports = { verifyMiddleware }最后，在需要鉴权的路由中引用这个中间件, /server/index.js：const { Router } = require('express') const { verifyMiddleware } = require('../middleware/verify.js') const router = Router() // 在需要鉴权的路由加上 router.get('/test', verifyMiddleware, function (req, res, next) { res.json({name: 'test'}) })静态文件的处理根目录下有个/static文件夹，我们希望这里面的文件可以直接通过url访问，需要在/server/index.js中加入一句：const express = require('express') const app = express() app.use('/static', express.static('static'))四、Nuxt开发相关生命周期Nuxt扩展了Vue的生命周期，大概如下：export default { middleware () {}, //服务端 validate () {}, // 服务端 asyncData () {}, //服务端 fetch () {}, // store数据加载 beforeCreate () { // 服务端和客户端都会执行}, created () { // 服务端和客户端都会执行 }, beforeMount () {}, mounted () {} // 客户端 }asyncData该方法是Nuxt最大的一个卖点，服务端渲染的能力就在这里，首次渲染时务必使用该方法。asyncData会传进一个context参数，通过该参数可以获得一些信息，如：export default { asyncData (ctx) { ctx.app // 根实例 ctx.route // 路由实例 ctx.params //路由参数 ctx.query // 路由问号后面的参数 ctx.error // 错误处理方法 } }渲染出错和ajax请求出错的处理asyncData渲染出错使用asyncData钩子时可能会由于服务器错误或api错误导致无法渲染，此时页面还未渲染出来，需要针对这种情况做一些处理，当遇到asyncData错误时，跳转到错误页面，nuxt提供了context.error方法用于错误处理，在asyncData中调用该方法即可跳转到错误页面。export default { async asyncData (ctx) { // 尽量使用try catch的写法，将所有异常都捕捉到 try { throw new Error() } catch { ctx.error({statusCode: 500, message: '服务器开小差了~' }) } } }这样，当出现异常时会跳转到默认的错误页，错误页面可以通过/layout/error.vue自定义。这里会遇到一个问题，context.error的参数必须是类似{ statusCode: 500, message: '服务器开小差了~' }，statusCode必须是http状态码，而我们服务端返回的错误往往有一些其他的自定义代码，如{resultCode: 10005, resultInfo: '服务器内部错误' }，此时需要对返回的api错误进行转换一下。为了方便，我引入了/plugins/ctx-inject.js为context注册一个全局的错误处理方法： context.$errorHandler(err)。注入方法可以参考：注入 $root 和 context，ctx-inject.js:// 为context注册全局的错误处理事件 export default (ctx, inject) => { ctx.$errorHandler = err => { try { const res = err.data if (res) { // 由于nuxt的错误页面只能识别http的状态码，因此statusCode统一传500，表示服务器异常。 ctx.error({ statusCode: 500, message: res.resultInfo }) } else { ctx.error({ statusCode: 500, message: '服务器开小差了~' }) } } catch { ctx.error({ statusCode: 500, message: '服务器开小差了~' }) } } }然后在nuxt.config.js使用该插件：export default { plugins: [ '~/plugins/ctx-inject.js' ] }注入完毕，我们就可以在asyncData介个样子使用了：export default { async asyncData (ctx) { // 尽量使用try catch的写法，将所有异常都捕捉到 try { throw new Error() } catch(err) { ctx.$errorHandler(err) } } }ajax请求出错对于ajax的异常，此时页面已经渲染，出现错误时不必跳转到错误页，可以通过this.$toast.error(res.message) toast出来即可。loading方法nuxt内置了页面顶部loading进度条的样式推荐使用，提供页面跳转体验。打开： this.$nuxt.$loading.start()完成： this.$nuxt.$loading.finish()打包部署一般来说，部署前可以先在本地打包，本地跑一下确认无误后再上传到服务器部署。命令：// 打包 npm run build // 本地跑 npm start除node_modules，.git,.env，将其他的文件都上传到服务器，然后通过pm2进行管理,可以在项目根目录建一个pm2.json方便维护：{ "name": "nuxt-test", "script": "./server/index.js", "instances": 2, "cwd": "." }然后配置生产环境的环境变量，一般是直接用.env.prod的配置：cp ./.env.prod ./.env。首次部署或有新的依赖包，需要在服务器上npm install一次，然后就可以用pm2启动进程啦：// 项目根目录下运行 pm2 start ./pm2.json需要的话，可以设置开机自动启动pm2: pm2 save && pm2 startup。需要注意的是，每次部署都得重启一下进程:pm2 reload nuxt-test。五、最后Nuxt.js引入了Node，同时nuxt.config.js替代了main.js的一些作用，目录结构和vue项目都稍有不同，增加了很多的约定，对于初次接触的同学可能会觉得非常陌生，更多的内容还是得看一遍官方的文档。demo源码: fengxianqi/front_end-demos/src/nuxt-test。
最近一直在折腾mpvue写的微信小程序的性能优化，分享下实战的过程。先上个优化前后的图：可以看到打包后的代码量从813KB减少到387KB,Audits体验评分从B到A，效果还是比较明显的。其实这个指标说明不了什么，而且轻易就可以做到，更重要的是优化小程序运行过程中的卡顿感，请耐心往下看。常规优化常规的Web端优化方法在小程序中也是适用的，而且不可忽视。一、压缩图片这一步最简单，但是容易被忽视。在tiny上在线压缩，然后下载替换即可。我这项目的压缩率高达72%，可以说打包后的代码从813KB降到387KB大部分都是归功于压缩图片了。二、移除无用的库我之前在项目中使用了Vant Weapp，在static目录下引入了整个库，但实际上我只使用了button,field,dialog等几个组件，实在是没必要。所以干脆移除掉了，微信小程序自身提供的button，wx.showModal等一些组件基本可以满足需求，自己手写一下样式也不用花什么时间。在这里建议大家，在微信小程序中，尽量避免使用过多的依赖库。不要贪图方便而引入一些比较大的库，小程序不同于Web，限制比较多，能自己写一下就尽量自己写一下吧。小程序的优化咱们首先得看一下官方优化建议，大多是围绕这个建议去做。一、开启Vue.config._mpTrace = true这个是mpvue性能优化的一个黑科技啊，可能大多数同学都不知道这个，我在官方文档都没有搜到到这个配置，我真的是服了。我能找到这个配置也是Google机缘巧合下看到的，出处：mpvue重要更新，页面更新机制进行全面升级具体做法是在/src/main.js添加Vue.config._mpTrace = true，如：Vue.config._mpTrace = true Vue.config.productionTip = false App.mpType = 'app'添加了Vue.config._mpTrace属性，这样就可以看到console里会打印每500ms更新的数据量。如图：如果数据更新量很大，会明显感觉小程序运行卡顿，反之就流畅。因此我们可以根据这个指标，逐步找出性能瓶颈并解决掉。二、精简data1. 过滤api返回的冗余数据后端的api可能是需要同时为iOS，Android，H5等提供服务的，往往会有些冗余的数据小程序是用不到的。比如api返回的一个文章列表数据有很多字段：this.articleList = [ { articleId: 1, desc: 'xxxxxx', author: 'fengxianqi', time: 'xxx', comments: [ { userId: 2, conent: 'xxx' } ] }, { articleId: 2 // ... }, // ... ]假设我们在小程序中只需要用到列表中的部分字段，如果不对数据做处理，将整个articleList都setData进去，是不明智的。小程序官方文档： 单次设置的数据不能超过1024kB，请尽量避免一次设置过多的数据。可以看出，内存是很宝贵的，当articleList数据量非常大超过1M时，某些机型就会爆掉（我在iOS中遇到过很多次）。因此，需要将接口返回的数据剔除掉不需要的，再setData，回到我们上面的articleList例子，假设我们只需要用articleId和author这两个字段，可以这样：import { getArticleList } from '@/api/article' export default { data () { return { articleList: [] } } methods: { getList () { getArticleList().then(res => { let rawList = res.list this.articleList = this.simplifyArticleList(rawList) }) }, simplifyArticleList (list) { return list.map(item => { return { articleId: item.articleId, author: item.author // 需要哪些字段就加上哪些字段 } }) } } }这里我们将返回的数据通过simplifyArticleList 来精简数据，此时过滤后的articleList中的数据类似：[ {articleId: 1, author: 'fengxianqi'}, {articleId: 2, author: 'others'} // ... ]当然，如果你的需求中是所有数据都要用到（或者大部分数据），就没必要做一层精简了，收益不大。毕竟精简数据的函数中具体的字段，是会增加维护成本的。PS： 在我个人的实际操作中，做数据过滤虽然增加了维护的成本，但一般收益都很大，因次这个方法比较推荐。2. data()中只放需要的数据import xx from 'xx.js' export default { data () { return { xx, otherXX: '2' } } }有些同学可能会习惯将import的东西都先放进data中，再在methods中使用，在小程序中可能是个不好的习惯。因为通过Vue.config._mpTrace = true在更新某个数据时，我对比放进data和不放进data中的两种情况会有差别。所以我猜测可能是data是会一起更新的，比如只是想更新otherXX时，会同时将xx也一起合起来setData了。3. 静态图片放进static这个问题和上面的问题其实是一样的，有时候我们会通过import的方式引入，比如这样： 这样会导致打包后的代码，图片是base64形式（很长的一段字符串）存放在data中，不利于精简data。同时当该组件多个地方使用时，每个组件实例都会携带这一段很长的base64代码，进一步导致数据的冗余。因此，建议将静态图片放到static目录下，这样引用： 代码也更简洁清爽。看一下做了上面操作的前后对比图，使用体验上也流畅了很多。三、swiper优化小程序自身提供的swiper组件性能上不是很好，使用时要注意。参考着两个思路：【优化】解决swiper渲染很多图片时的卡顿想请教一下小程序swiper组件的问题在我使用时，由于需求原因，动态删掉swiper-item的思路不可行（手滑时会造成抖动）。因此只能作罢。但仍然可以优化一下：将未显示的swiper-item中的图片用v-if隐藏到，当判断到current时才显示，防止大量图片的渲染导致的性能问题。四、vuex使用注意事项我之前写过的一篇mpvue开发音频类小程序踩坑和建议里面有讲如何在小程序中使用vuex。但遇到了个比较严重的性能问题。1. 问题描述我开发的是一个音频类的小程序，所以需要将播放列表playList,当前索引currentIndex和当前时长currentTime放进state.js中：const state = { currentIndex: 0, // playList当前索引 currentTime: 0, // 当前播放的进度 playList: [], // {title: '', url: '', singer: ''} }每次用户点击播放音频时，都会先加载音频的播放列表playList,然后播放时更新当前时长currentTime，发现有时候播音频时整个小程序非常卡顿。注意到，音频需每秒就得更新一次currentTime,即每秒就做一次setData操作，稍微有些卡顿是可以理解的。但我发现是播放列表数据比较多时会特别卡，比如playList的长度是100条以上时。2. 问题原因我开启Vue.config._mpTrace = true后发现一个规律：当palyList数据量小时，console显示造成的数据量更新数值比较小；当playList比较大时，console显示造成的数据量更新数值比较大。PS： 我曾尝试将playList数据量增加到200条，每500ms的数据量更新达到800KB左右。到这里基本可以确定一个事实就是：更新state中的任何一个字段，将导致整个state全量一起setData。在我这里的例子，虽然我每次只是更新currentTime这个字段的值，但依然导致将state中的其他字段如playList,currentIndex都一起做了一次setData操作。3. 解决问题有两个思路：精简state中保存的数据，即限制playList的数据不能太多，可将一些数据暂存在storage中vuex采用Module的写法能改善这个问题，虽然使用时命名空间造成一定的麻烦。vuex传送门一般情况下，推荐使用后者。我在项目中尝试使用了前者，同样能达到很好的效果，请继续看下面的分享。五、善用storage1.为什么说要善用storage由于小程序的内存非常宝贵，占用内存过大会非常卡顿，因此最好尽可能少的将数据放到内存中，即vuex存的数据要尽可能少。而小程序的storage支持单个 key允许存储的最大数据长度为 1MB，所有数据存储上限为 10MB。所以可以将一些相对取用不频繁的数据放进storage中，需要时再将这些数据放进内存，从而缓解内存的紧张，有点类似Windows中虚拟内存的概念。2.storage换内存的实例这个例子讲的会有点啰嗦，真正能用到的朋友可以详细看下。上面讲到playList数据量太多，播放一条音频时其实只需要最多保证3条数据在内存中即可，即上一首，播放中的，下一首,我们可以将多余的播放列表存放在storage中。PS: 为了保证更平滑地连续切换下一首，我们可以稍微保存多几条，比如我这里选择保存5条数据在vuex中，播放时始终保证当前播放的音频前后都有两条数据。// 首次播放背景音频的方法 async function playAudio (audioId) { // 拿到播放列表，此时的playList最多只有5条数据。getPlayList方法看下面 const playList = await getPlayList(audioId) // 当前音频在vuex中的currentIndex const currentIndex = playList.findIndex(item => item.audioId === audioId) // 播放背景音频 this.audio = wx.getBackgroundAudioManager() this.audio.title = playList[currentIndex].title this.audio.src = playList[currentIndex].url // 通过mapActions将播放列表和currentIndex更新到vuex中 this.updateCurrentIndex(index) this.updatePlayList(playList) // updateCurrentIndex和updatePlayList是vuex写好的方法 } // 播放音频时获取播放列表的方法，将所有数据存在storage，然后返回当前音频的前后2条数据，保证最多5条数据 import { loadPlayList } from '@/api/audio' async function getPlayList (courseId, currentAudioId) { // 从api中请求得到播放列表 // loadPlayList是api的方法, courseId是获取列表的参数,表示当前课程下的播放列表 let rawList = await loadPlayList(courseId) // simplifyPlayList过滤掉一些字段 const list = this.simplifyPlayList(rawList) // 将列表存到storage中 wx.setStorage({ key: 'playList', data: list }) return subPlayList(list, currentAudioId) }重点是subPlayList方法，这个方法保证了拿到的播放列表是最多5条数据。function subPlayList(playList, currentAudioId) { let tempArr = [...playList] const count = 5 // 保持vuex中最多5条数据 const middle = parseInt(count / 2) // 中点的索引 const len = tempArr.length // 如果整个原始的播放列表本来就少于5条数据，说明不需要裁剪，直接返回 if (len <= count) { return tempArr } // 找到当前要播放的音频的所在位置 const index = tempArr.findIndex(item => item.audioId === currentAudioId) // 截取当前音频的前后两条数据 tempArr = tempArr.splice(Math.max(0, Math.min(len - count, index - middle)), count) return tempArr }tempArr.splice(Math.max(0, index - middle), count)可能有些同学比较难理解，需要仔细琢磨一下。假设playList有10条数据：当前音频是列表中的第1条（索引是0）,截取前5个：playList.splice(0, 5)，此时currentAudio在这5个数据的索引是0,没有上一首，有4个下一首当前音频是列表中的第2条（索引是1）,截取前5个：playList.splice(0, 5)，此时currentAudio在这5个数据的索引是1，有1个上一首，3个下一首当前音频是列表中的第3条（索引是2）,截取前5个：playList.splice(0, 5)，此时currentAudio在这5个数据的索引是2，有2个上一首，2个下一首当前音频是列表中的第4条（索引是3）,截取第1到6个：playList.splice(1, 5)，此时currentAudio在这5个数据的索引是2，有2个上一首，2个下一首当前音频是列表中的第5条（索引是4），截取第2到7个：playList.splice(2, 5)，此时currentAudio在这5个数据的索引是2，有2个上一首，2个下一首...当前音频是列表中的第9条（索引是8），截取后5个：playList.splice(4, 5)，此时currentAudio在这5个数据的索引是3，有3个上一首，1个下一首当前音频是列表中的最后1条（索引是9），截取后的5个：playList.splice(4, 5)，此时currentAudio在这5个数据的索引是4，有4个上一首，没有下一首有点啰嗦，感兴趣的同学仔细琢磨下，无论当前音频在哪，都始终保证了拿到当前音频前后的最多5条数据。接下来就是维护播放上一首或下一首时保证当前vuex中的playList始终是包含当前音频的前后2条。播放下一首function playNextAudio() { const nextIndex = this.currentIndex + 1 if (nextIndex < this.playList.length) { // 没有超出数组长度，说明在vuex的列表中，可以直接播放 this.audio = wx.getBackgroundAudioManager() this.audio.src = this.playList[nextIndex].url this.audio.title = this.playList[nextIndex].title this.updateCurrentIndex(nextIndex) // 当判断到已经到vuex的playList的边界了，重新从storage中拿数据补充到playList if (nextIndex === this.playList.length - 1 || nextIndex === 0) { // 拿到只有当前音频前后最多5条数据的列表 const newList = getPlayList(this.playList[nextIndex].courseId, this.playList[nextIndex].audioId) // 当前音频在这5条数据中的索引 const index = newList.findIndex(item => item.audioId === this.playList[nextIndex].audioId) // 更新到vuex this.updateCurrentIndex(index) this.updatePlayList(newList) } } }这里的getPlayList方法是上面讲过的，本来是从api中直接获取的，为了避免每次都从api直接获取，所以需要改一下，先读storage，若无则从api获取：import { loadPlayList } from '@/api/audio' async function getPlayList (courseId, currentAudioId) { // 先从缓存列表中拿 const playList = wx.getStorageSync('playList') if (playList && playList.length > 0 && courseId === playList[0].courseId) { // 命中缓存，则从直接返回 return subPlayList(playList, currentAudioId) } else { // 没有命中缓存，则从api中获取 const list = await loadPlayList(courseId) wx.setStorage({ key: 'playList', data: list }) return subPlayList(list, currentAudioId) } }播放上一首也是同理，就不赘述了。PS: 将vuex中的数据精简后，我所做的小程序在播放音频时刷其他页面已经非常流畅啦，效果非常好。六、动画优化这个问题在mpvue开发音频类小程序踩坑和建议已经讲过了，感兴趣的可以移步看一眼，这里只写下概述:如果要使用动画，尽量用css动画代替wx.createAnimation使用css动画时建议开启硬件加速最后大致总结一下上面所讲的几个要点：开发时打开Vue.config._mpTrace = true。谨慎引入第三方库，权衡收益。添加数据到data中时要克制，能精简尽量精简。图片记得要压缩，图片在显示时才渲染。vuex保持数据精简，必要时可先存storage。性能优化是一个永不止步的话题，我也还在摸索，不足之处还请大家指点和分享。欢迎关注，会持续分享前端实战中遇到的一些问题和解决办法。