SegmentFault 前端实战笔录最新的文章
2020-12-29T07:34:03+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
为什么说rollup比webpack更适合打包库
https://segmentfault.com/a/1190000038708512
2020-12-29T07:34:03+08:00
2020-12-29T07:34:03+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
14
<h2>前言</h2><p>大概一年前写了个小小的js插件 <a href="https://link.segmentfault.com/?enc=tJ4vgFWr6frjdCVMIBle5w%3D%3D.SYuXbV1ddIIZGIqpja8DFgusZMIKshe7uAqzBXKsBCUY%2B0U1CJX1K1GZzEWAbBEA" rel="nofollow">remember-scroll</a>,并且分享了一篇文章:<a href="https://segmentfault.com/a/1190000018569836">用Class写一个记住用户离开位置的js插件</a>,是一个纯js库,功能是在用户再次进入页面时能自动定位到上一次浏览的位置,使用webpack+babel打包,里面的webpack和babel的配置至今看来也算是很典型的。</p><p>前端打包工具有很多——<code>webpack</code>,<code>gulp</code>,<code>rollup</code>等等,网上有很多文章分析它们分别更适合哪些场景,<strong><code>webpack</code>更适合打包组件库、应用程序之类的应用,而<code>rollup</code>更适合打包纯js的类库</strong>。因此笔者一直有想法尝试将 <a href="https://link.segmentfault.com/?enc=xfcXqJrABfUWG1dmn9eyeg%3D%3D.5S%2FvxeLNQsaaKXqBThszUe1zwkMuArEOfPMN58NZLXVegbxPwcbbdwVFl2MX9PpL" rel="nofollow">remember-scroll</a> 的打包工具由<code>webpack</code>更换为<code>rollup</code>,从实际应用的角度来对比一下两者的区别。</p><h2>从零配置rollup</h2><ol><li>安装rollup和一些插件</li></ol><pre><code>npm i rollup rollup-plugin-uglify rollup-plugin-filesize @rollup/plugin-node-resolve @rollup/plugin-commonjs -D</code></pre><ul><li><code>rollup-plugin-uglify</code> 用于压缩混淆打包后的js。</li><li><code>rollup-plugin-filesize</code>打包后在控制台显示文件大小。</li><li><code>@rollup/plugin-node-resolve</code>让 <code>rollup</code> 能够识别node_modules的第三方模块。</li><li><code>@rollup/plugin-commonjs</code>将 CommonJS 的模块转换为 ES2015 供 <code>rollup</code> 处理。</li></ul><ol><li>添加babel</li></ol><pre><code>npm i @rollup/plugin-babel @babel/core @babel/plugin-transform-runtime @babel/preset-env core-js@2 -D</code></pre><ul><li><code>@rollup/plugin-babel</code> rollup 的babel插件。</li><li><code>@babel/core</code> babel核心。</li><li><code>@babel/plugin-transform-runtime</code> 用于避免污染全局函数(不是必须要用到,但作为类库最好要加上)。</li><li><code>@babel/preset-env</code> 自动根据目标浏览器注入相关的polyfill。</li><li><code>core-js</code> polyfill的类库,这里使用的是2.x版本(使用3会增加包的大小)。</li></ul><p>根目录下的<code>babel.config.js</code>如下:</p><pre><code class="javascript">const presets = [
[
'@babel/env',
{
useBuiltIns: 'usage',
corejs: { version: 2 }
},
],
]
const plugins = [
'@babel/plugin-transform-runtime'
]
module.exports = { presets, plugins }</code></pre><ol><li>根目录下新建<code>rollup.config.js</code>,全部配置如下:</li></ol><pre><code class="javascript">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())
]
}</code></pre><ol><li><code>package.json</code>打包命令如下:</li></ol><pre><code> "build": "rollup -c --environment NODE_ENV:production && rollup -c",
"dev": "rollup -c --watch",</code></pre><p>总之,一切配置都是与之前<code>webpack</code>版本的一样,都使用了babel。<code>npm run build</code>就可以将资源打包到dist了,接下来我们对比一下<code>webpack</code>和<code>rollup</code>两个工具打出来的体积有啥区别。</p><h2>webpack和rollup打包体积对比</h2><p>笔者特地建了一个同时有rollup和webpack打包出来的资源的分支,大家可以直接看下github上<code>feature/webpack_rollup</code>分支的<a href="https://link.segmentfault.com/?enc=g6etc4NnlQvGuyoYxAZj3A%3D%3D.q7%2BqIZDrsxaGMbwsTTJo3czVnYDwfO3A%2Bw8FrkAcNY3rVvzNGUNxzHzY107a%2BM8XZxbWviI74R1SheIfnmwtVt%2FpYE8RUbbSMSCBkYr%2B2Es%3D" rel="nofollow">remember-scroll/dist</a>,对比结果如下:</p><table><thead><tr><th>-</th><th>webpack</th><th>rollup</th></tr></thead><tbody><tr><td>开发模式大小</td><td>52.8KB</td><td>19.46KB</td></tr><tr><td>生产打包大小</td><td>10.3KB</td><td>7.66KB</td></tr><tr><td>生产包gzip后大小</td><td>4.1KB</td><td>3.4KB</td></tr></tbody></table><p>可见,<code>rollup</code>打包出来的体积都比<code>webpack</code>略小一些,通过查看打包出来的代码,webpack打包出来的文件里面有很多<code>__webpack_require__</code>工具函数的定义,可读性也很差,而rollup打包出来的js会简单一点。</p><blockquote>项目的<a href="https://link.segmentfault.com/?enc=iO8ZOTnqOhKqVyO3FKtv8g%3D%3D.7gV0pQW80N0oJfSrPXwsbmQ%2BDniKp9IMSAVOAY9SNVVms2qpFe3UvjMCRgP%2BYzRU" rel="nofollow">master</a>分支已经改为使用rollup进行构建,<a href="https://link.segmentfault.com/?enc=cj6Dsx1sKOzNgmPytpblEA%3D%3D.3KYGOiP%2F4LHPwXTMdplwDjFMCKtLqlyNVZzm20MibQYgRImitM6Geyj36J%2ByfOdpqg2OOHZg11u%2FYx%2BnbI%2Fru6y9KjfiYXq76h7x%2FRp4vns%3D" rel="nofollow">feature/webpack</a>分支保留了之前webpack的配置,感兴趣的同学可以去github上详细了解下。</blockquote><p>不得不说,从打包体积上来看,使用<code>rollup</code>构建无疑是更适合的。</p><h2>package.json的main指向问题</h2><p>笔者之前遇到过一个问题是<code>package.json</code>的<code>main</code>到底应该是指向构建后的开发版本还是生产版本呢?</p><p><a href="https://link.segmentfault.com/?enc=6ibrGL6QIVFRUZJrJ1qAcA%3D%3D.baMzw8h219gev7vlViuS0SSyfHa14Fj5wYzzgYmEOCcyEMLvFM6tJgoB31x%2BecLur8ICne2Rw%2BZ50ulKuvPzkA%3D%3D" rel="nofollow">关于package.json中main字段的指向问题</a>这篇文章给了答案:<strong>main应该指向开发版本。</strong></p><p>这里会有一个疑问:引用开发版本的包体积很大,岂不是让我的应用打包上线版本很大?</p><p>为了验证上面 package.json 的<code>main</code>指向开发或生产版本有什么不同,笔者这里直接实战做个对比。</p><p>使用VueCli v4.5.9 新建一个vue项目,然后在<code>App.vue</code>引入不同工具打包而成的<code>remember-scroll</code>,再<code>npm run build</code>打包该vue项目,对比打包出来<code>chunk-vendors.[hash].js</code>的体积。</p><blockquote>引入的npm包默认会打包进<code>chunk-vendors</code>,<code>app.js</code>的增量体积都是一样的就不作对比了。</blockquote><table><thead><tr><th>-</th><th>vue打包</th><th>webpack开发版(52.8KB)</th><th>webpack生产版(10.3KB)</th><th>rollup开发版(19.46KB,推荐)</th><th>rollup生产版(7.66KB)</th></tr></thead><tbody><tr><td>Size</td><td>89.42</td><td>134.97</td><td>99.34</td><td>97.29</td><td>96.96</td></tr><tr><td>Gzip</td><td>32.04</td><td>40.47</td><td>34.60</td><td>34.52</td><td>34.51</td></tr></tbody></table><p>可以看到,使用<code>rollup</code>打包的,无论main指向开发版还是生产版,gzip后几乎一致,但<code>webpack</code>打包出来的,main指向开发版时体积会相差非常大。</p><p>所以使用<code>webpack</code>打包的插件,一般都是会根据<code>NODE_ENV</code>来加载对应的包,<code>NODE_ENV === 'production'</code>时指向压缩后的生产版本。比如像下面这样,在根目录新建一个<code>index.js</code>,<code>package.json</code>的<code>main</code>指向该文件,然后在js中写上:</p><pre><code class="javascript">if (process.env.NODE_ENV === 'production') {
module.exports = require('./dist/remember-scroll.min.js')
} else {
module.exports = require('./dist/remember-scroll.js')
}</code></pre><p>倘若各位以后要写一个用webpack打包的插件,要特别注意这一点。</p><p>而如果用rollup打包的,其实就不用在意这个细节啦,在这个环节<code>rollup</code>又比<code>webpack</code>更香一点哈哈哈。</p><h2>总结</h2><p>通过实战,功能不变且浏览器兼容性一致的情况下,对<a href="https://link.segmentfault.com/?enc=HNlD0ASVwn5TO7Hiqg0k3g%3D%3D.rzUIn7vopiBIPg92FW%2BRpkW%2BxC9iiYRWD3QG2FMV%2F%2FJYwFyJD7DISIkfbHW2dlcs" rel="nofollow">remember-scroll</a>这个js库来说,使用<code>rollup</code>打包确实比<code>webpack</code>会更合适一些。所以如果我们以后要做技术选型,对于纯js的类库,选择使用<code>rollup</code>会更合适一点。</p><p>当然,<code>rollup</code>和<code>webpack</code>都是作为构建工具,它们都有着各自的优势和各自的使用场景,利用好它们的优点就可以了。</p>
微前端qiankun从搭建到部署的实践
https://segmentfault.com/a/1190000024551391
2020-09-23T08:00:00+08:00
2020-09-23T08:00:00+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
52
<p>最近负责的新项目用到了<code>qiankun</code>,写篇文章分享下实战中遇到的一些问题和思考。</p><p>示例代码: <a href="https://link.segmentfault.com/?enc=j2xw4%2FVHTfOjfbuqhm0nRg%3D%3D.VXQxgkU%2Bu1kuf0OkbUvYrnnHETr596vg%2FevFAq9iJ2iFlitX%2FmuYJsH%2Btw3Iu%2BtJ" rel="nofollow">https://github.com/fengxianqi/qiankun-example</a>。</p><p>在线demo:<a href="https://link.segmentfault.com/?enc=NsLik2AWMd%2FWOvsH%2B1RsVQ%3D%3D.cq1oNksTrW7oO8v7NgHU7MZn81J%2FZGD7tuvh3Ia3hkQ%3D" rel="nofollow">http://qiankun.fengxianqi.com/</a></p><p>单独访问在线子应用:</p><ul><li><a href="https://link.segmentfault.com/?enc=EpC%2FaqdiYF6vFFjR2rlbPQ%3D%3D.Fyc7t7GfQKVlDEG6t%2FiUcqrff9Nb4ZqQ%2BwONo%2FCpuB0NsED8INpeVQCjcg%2Btmu5V" rel="nofollow">subapp/sub-vue</a></li><li><a href="https://link.segmentfault.com/?enc=dnhdga6vnPL3gUrAGZhkUw%3D%3D.CqPGm2rCRCYF%2FhncJZR9oiRQq3Ns4ZvaY1jeCOY4xGzcvtqfkpk387AByYN%2FGKff" rel="nofollow">subapp/sub-react</a></li></ul><h2>为什么要用qiankun</h2><p>项目有个功能需求是需要内嵌公司内部的一个现有工具,该工具是独立部署的且是用<code>React</code>写的,而我们的项目主要技术选型是<code>vue</code>,因此需要考虑嵌入页面的方案。主要有两条路:</p><ul><li><code>iframe</code>方案</li><li><code>qiankun</code>微前端方案</li></ul><p>两种方案都能满足我们的需求且是可行的。不得不说,<code>iframe</code>方案虽然普通但很实用且成本也低,<code>iframe</code>方案能覆盖大部分的微前端业务需求,而<code>qiankun</code>对技术要求更高一些。</p><p>技术同学对自身的成长也是有强烈需求的,因此在两者都能满足业务需求时,我们更希望能应用一些较新的技术,折腾一些未知的东西,因此我们决定选用<code>qiankun</code>。</p><h2>项目架构</h2><p>后台系统一般都是上下或左右的布局。下图粉红色是基座,只负责头部导航,绿色是挂载的整个子应用,点击头部导航可切换子应用。<br><img src="/img/remote/1460000024551394" alt="image" title="image"></p><p>参考官方的<a href="https://link.segmentfault.com/?enc=M5kYTtDtldVV5ieTvy%2Fb%2BQ%3D%3D.%2BXvmKDPaJt3uk6lKFyKOTGTtJcqtK4wciux5rL99wpM1cdUin8WciCGxFIFRWF3ZzDyLvvacLTtnq4t6%2BD9I2g%3D%3D" rel="nofollow">examples</a>代码,项目根目录下有基座<code>main</code>和其他子应用<code>sub-vue</code>,<code>sub-react</code>,搭建后的初始目录结构如下:</p><pre><code>
├── common //公共模块
├── main // 基座
├── sub-react // react子应用
└── sub-vue // vue子应用</code></pre><p>基座是用<code>vue</code>搭建,子应用有<code>react</code>和<code>vue</code>。</p><h2>基座配置</h2><p>基座main采用是的Vue-Cli3搭建的,它只负责导航的渲染和登录态的下发,为子应用提供一个挂载的容器div,基座应该保持简洁(qiankun官方demo甚至直接使用原生html搭建),不应该做涉及业务的操作。</p><p>qiankun这个库只需要在基座引入,在<code>main.js</code>中注册子应用,为了方便管理,我们将子应用的配置都放在:<code>main/src/micro-app.js</code>下。</p><pre><code class="javascript">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
</code></pre><p>然后在<code>src/main.js</code>中引入</p><pre><code class="javascript">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();</code></pre><p>在<code>App.vue</code>中,需要声明<code>micro-app.js</code>配置的子应用挂载div(注意id一定要一致),以及基座布局相关的,大概这样:</p><pre><code class="html"><template>
<div id="layout-wrapper">
<div class="layout-header">头部导航</div>
<div id="subapp-viewport"></div>
</div>
</template>
</code></pre><p>这样,基座就算配置完成了。项目启动后,子应用将会挂载到<code><div id="subapp-viewport"></div></code>中。</p><h2>子应用配置</h2><h3>一、vue子应用</h3><p>用Vue-cli在项目根目录新建一个<code>sub-vue</code>的子应用,子应用的名称最好与父应用在<code>src/micro-app.js</code>中配置的名称一致(这样可以直接使用<code>package.json</code>中的<code>name</code>作为output)。</p><ol><li>新增<code>vue.config.js</code>,devServer的端口改为与主应用配置的一致,且加上跨域<code>headers</code>和<code>output</code>配置。</li></ol><pre><code class="javascript">// 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': '*' // 主应用获取子应用时跨域响应头
}
}
}</code></pre><ol><li>新增<code>src/public-path.js</code></li></ol><pre><code class="javascript">(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__;
}
})();</code></pre><ol><li><code>src/router/index.js</code>改为只暴露routes,<code>new Router</code>改到<code>main.js</code>中声明。</li><li>改造<code>main.js</code>,引入上面的<code>public-path.js</code>,改写render,添加生命周期函数等,最终如下:</li></ol><pre><code class="javascript">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
}</code></pre><p>至此,基础版本的vue子应用配置好了,如果<code>router</code>和<code>vuex</code>不需用到,可以去掉。</p><h3>二、react子应用</h3><ol><li>通过<code>npx create-react-app sub-react</code>新建一个react应用。</li><li>新增<code>.env</code>文件添加<code>PORT</code>变量,端口号与父应用配置的保持一致。</li><li>为了不<code>eject</code>所有webpack配置,我们用<a href="https://link.segmentfault.com/?enc=iHIXFKC6SPtA7SE1PtAG7A%3D%3D.MUsY1QRiypcOLjiQpGC8FvVL07vkobAIYnL%2Fwub4%2Fwc6gUaINYrqtWq2Dk%2BRn9I5" rel="nofollow">react-app-rewired</a>方案复写webpack就可以了。</li></ol><ul><li>首先<code>npm install react-app-rewired --save-dev</code></li><li>新建<code>sub-react/config-overrides.js</code></li></ul><pre><code class="javascript">const { 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;
};
},
};</code></pre><ol><li>新增<code>src/public-path.js</code>。</li></ol><pre><code class="javascript">if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}</code></pre><ol><li>改造<code>index.js</code>,引入<code>public-path.js</code>,添加生命周期函数等。</li></ol><pre><code class="javascript">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();</code></pre><p>至此,基础版本的react子应用配置好了。</p><h2>进阶</h2><h3>全局状态管理</h3><p><code>qiankun</code>通过<a href="https://link.segmentfault.com/?enc=Id0OH1fHCjVg1hdSm0x3Tw%3D%3D.T4GV9k4R%2BriwqdoD39OuvPjldR2Q%2BMSFxRI6G5xbzUfje%2F6zr0qNlnVKg00WdypGCQb8GcCxSSwhl%2BvGz%2Bac3g%3D%3D" rel="nofollow">initGlobalState, onGlobalStateChange, setGlobalState</a>实现主应用的全局状态管理,然后默认会通过<code>props</code>将通信方法传递给子应用。先看下官方的示例用法:</p><p>主应用:</p><pre><code class="javascript">// 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();</code></pre><p>子应用:</p><pre><code class="javascript">// 从生命周期 mount 中获取通信方法,props默认会有onGlobalStateChange和setGlobalState两个api
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}</code></pre><p>这两段代码不难理解,父子应用通过<code>onGlobalStateChange</code>这个方法进行通信,这其实是一个发布-订阅的设计模式。</p><p>ok,官方的示例用法很简单也完全够用,纯JavaScript的语法,不涉及任何的vue或react的东西,开发者可自由定制。</p><p>如果我们直接使用官方的这个示例,那么数据会比较松散且调用复杂,所有子应用都得声明<code>onGlobalStateChange</code>对状态进行监听,再通过<code>setGlobalState</code>进行更新数据。</p><p>因此,我们很有必要<strong>对数据状态做进一步的封装设计</strong>。笔者这里主要考虑以下几点:</p><ul><li>主应用要保持简洁简单,对子应用来说,主应用下发的数据就是一个很纯粹的<code>object</code>,以便更好地支持不同框架的子应用,因此主应用不需用到<code>vuex</code>。</li><li>vue子应用要做到能继承父应用下发的数据,又支持独立运行。</li></ul><p>子应用在<code>mount</code>声明周期可以获取到最新的主应用下发的数据,然后将这份数据注册到一个名为<code>global</code>的vuex module中,子应用通过global module的action动作进行数据的更新,更新的同时自动同步回父应用。</p><p>因此,对子应用来说,<strong>它不用知道自己是一个qiankun子应用还是一个独立应用,它只是有一个名为<code>global</code>的module,它可通过action更新数据,且不再需要关心是否要同步到父应用</strong>(同步的动作会封装在方法内部,调用者不需关心),这也是为后面<strong>支持子应用独立启动开发做准备</strong>。</p><ul><li>react子应用同理(笔者react用得不深就不说了)。</li></ul><p><img src="/img/remote/1460000024551395" alt="image" title="image"></p><h3>主应用的状态封装</h3><p>主应用维护一个<code>initialState</code>的初始数据,它是一个<code>object</code>类型,会下发给子应用。</p><pre><code class="javascript">// 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;</code></pre><p>这里有两个注意的地方:</p><ul><li><code>Vue.observable</code>是为了让父应用的state变成可响应式,如果不用Vue.observable包一层,它就只是一个纯粹的object,子应用也能获取到,但会失去响应式,<strong>意味着数据改变后,页面不会更新</strong>。</li><li><code>getGlobalState</code>方法,这个是<strong>有争议</strong>的,大家在github上有讨论:<a href="https://link.segmentfault.com/?enc=KmP2I26khGEL0pKvBs4pfw%3D%3D.slRacxszI0z8oL2KxM8%2BhcAKqvpJegpid2C9f9r%2B3wJEkTur8CRU%2FVl7sabWzV%2FO" rel="nofollow">https://github.com/umijs/qiankun/pull/729</a>。</li></ul><p>一方面,作者认为<code>getGlobalState</code>不是必须的,<code>onGlobalStateChange</code>其实已经够用。</p><p>另一方面,笔者和其他提pr的同学觉得有必要提供一个<code>getGlobalState</code>的api,理由是get方法更方便使用,子应用有需求是不需一直监听stateChange事件,它只需要在首次mount时通过getGlobalState初始化一次即可。在这里,笔者先坚持己见让父应用下发一个getGlobalState的方法。</p><p>由于官方还不支持getGlobalState,所以需要显示地在注册子应用时通过props去下发该方法:</p><pre><code class="javascript">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 microApps</code></pre><h3>vue子应用的状态封装</h3><p>前面说了,子应用在mount时会将父应用下发的state,注册为一个叫<code>global</code>的vuex module,为了方便复用我们封装一下:</p><pre><code class="javascript">// 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;</code></pre><p><code>main.js</code>中添加global-module的使用:</p><pre><code class="javascript">import globalRegister from './store/global-register'
export async function mount(props) {
console.log('[vue] props from main framework', props)
globalRegister(store, props)
render(props)
}</code></pre><p>可以看到,该vuex模块在子应用mount时,会调用<code>initGlobalState</code>将父应用下发的state初始化一遍,同时提供了<code>setGlobalState</code>方法供外部调用,内部自动通知同步到父应用。子应用在vue页面使用时如下:</p><pre><code class="javascript">export default {
computed: {
...mapState('global', {
user: state => state.user, // 获取父应用的user信息
}),
},
methods: {
...mapActions('global', ['setGlobalState']),
update () {
this.setGlobalState('user', { name: '张三' })
}
},
};</code></pre><p>这样就达到了一个效果:子应用不用知道qiankun的存在,它只知道有这么一个global module可以存储信息,父子之间的通信都封装在方法本身了,它只关心本身的信息存储就可以了。</p><blockquote>ps: 该方案也是有缺点的,由于子应用是在mount时才会同步父应用下发的state的。因此,它只适合每次只mount一个子应用的架构(不适合多个子应用共存);若父应用数据有变化而子应用又没触发mount,则父应用最新的数据无法同步回子应用。想要做到多子应用共存且父动态传子,子应用还是需要用到qiankun提供的<code>onGlobalStateChange</code>的api监听才行,有更好方案的同学可以分享讨论一下。该方案刚好符合笔者当前的项目需求,因此够用了,请同学们根据自己的业务需求来封装。</blockquote><h3>子应用切换Loading处理</h3><p>子应用首次加载时相当于新加载一个项目,还是比较慢的,因此loading是不得不加上的。</p><p><a href="https://link.segmentfault.com/?enc=JUAA7ZopptGhRF1K3lzPfQ%3D%3D.oRRTAiAO4%2FUfkN2EDMUgjpCDccy4qFxRI67o1hA%2FEF2SU9ijpl0p9Z13gdM98o0kK%2FGQNP%2Fp2u2D%2BcDDxIAWqSlLD4ttGtJOZH30GO58O0w%3D" rel="nofollow">官方的例子</a>中有做了loading的处理,但是需要额外引入<code>import Vue from 'vue/dist/vue.esm'</code>,这会增加主应用的打包体积(对比发现大概增加了100KB)。一个loading增加了100K,显然代价有点无法接受,所以需要考虑一种更优一点的办法。</p><p>我们的主应用是用vue搭建的,而且qiankun提供了<a href="https://link.segmentfault.com/?enc=lkG3b6O%2FCTuM9JcNnNaOAw%3D%3D.Gm0jxrFEZf7qm6K1AwFWM8bAF2FUubYh%2FgmTaAGTja7KfjdOkEN0%2BspC%2B9sLaSo7xk0wabR7dIU9C0MVtXTO1IvSAMNf9UnLu9QJNm%2FzH%2Bc%3D" rel="nofollow">loader</a>方法可以获取到子应用的加载状态,所以自然而然地可以想到:<strong>在<code>main.js</code>中子应用加载时,将loading 的状态传给Vue实例,让Vue实例响应式地显示loading。</strong>接下来先选一个loading组件:</p><ul><li>如果主应用使用了ElementUI或其他框架,可以直接使用UI库提供的loading组件。</li><li>如果主应用为了保持简单没有引入UI库,可以考虑自己写一个loading组件,或者找个小巧的loading库,如笔者这里要用到的<a href="https://link.segmentfault.com/?enc=aaH3sJufqe7qIBxh11%2B8EA%3D%3D.NRNHjGLDs1gXDZMPjmwLcCwrCfwZc9cG0lUM06oBQ%2BctlnfivmFG3f%2FVEQ3mC599" rel="nofollow">NProgress</a>。</li></ul><pre><code>npm install --save nprogress</code></pre><p>接下来是想办法如何把loading状态传给主应用的<code>App.vue</code>。经过笔者试验发现,<code>new Vue</code>方法返回的vue实例可以通过<code>instance.$children[0]</code>来改变<code>App.vue</code>的数据,所以改造一下<code>main.js</code>:</p><pre><code class="javascript">// 引入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();</code></pre><blockquote>PS: qiankun的registerMicroApps方法也监听到子应用的beforeLoad、afterMount等生命周期,因此也可以使用这些方法记录loading状态,但更好的用法肯定是通过loader参数传递。</blockquote><p>改造主应用的App.vue,通过watch监听<code>isLoading</code></p><pre><code class="html"><template>
<div id="layout-wrapper">
<div class="layout-header">头部导航</div>
<div id="subapp-viewport"></div>
</div>
</template>
<script>
import NProgress from 'nprogress'
export default {
name: 'App',
data () {
return {
isLoading: true
}
},
watch: {
isLoading (val) {
if (val) {
NProgress.start()
} else {
this.$nextTick(() => {
NProgress.done()
})
}
}
},
components: {},
created () {
NProgress.start()
}
}
</script></code></pre><p>至此,loading效果就实现了。虽然<code>instance.$children[0].isLoading</code>的操作看起来比较骚,但确实比官方的提供的例子成本小很多(体积增加几乎为0),若有更好的办法,欢迎大家评论区分享。</p><h3>抽取公共代码</h3><p>不可避免,有些方法或工具类是所有子应用都需要用到的,每个子应用都copy一份肯定是不好维护的,所以抽取公共代码到一处是必要的一步。</p><p>根目录下新建一个<code>common</code>文件夹用于存放公共代码,如上面的多个vue子应用都可以共用的<code>global-register.js</code>,或者是可复用的<code>request.js</code>和<code>sdk</code>之类的工具函数等。这里代码不贴了,请直接看<a href="https://link.segmentfault.com/?enc=WAQ0veqhomFkaO6PIBu4cw%3D%3D.hAxWQ2eW%2BPT16PNpOfWN%2F2RE0XkG45h2rRCi07dnpq68RV2pmZv%2FSTe12coiIRKVs5k%2Fev%2BgJ9U%2But1FlDynS9wqb8NKaaOGTAGzN0IfCe0%3D" rel="nofollow">demo</a>。</p><p>公共代码抽取后,其他的应用如何使用呢? 可以让common发布为一个npm私包,npm私包有以下几种组织形式:</p><ul><li>npm指向本地file地址:<code>npm install file:../common</code>。直接在根目录新建一个common目录,然后npm直接依赖文件路径。</li><li>npm指向私有git仓库: <code>npm install git+ssh://xxx-common.git</code>。</li><li>发布到npm私服。</li></ul><p>本demo因为是基座和子应用都集合在一个git仓库上,所以采用了第一种方式,但实际应用时是发布到npm私服,因为后面我们会拆分基座和子应用为独立的子仓库,支持独立开发,后文会讲到。</p><p>需要注意的是,由于common是不经过babel和pollfy的,所以引用者需要在webpack打包时显性指定该模块需要编译,如vue子应用的vue.config.js需要加上这句:</p><pre><code class="javascript">module.exports = {
transpileDependencies: ['common'],
}</code></pre><h3>子应用支持独立开发</h3><p>微前端一个很重要的概念是拆分,是分治的思想,把所有的业务拆分为一个个独立可运行的模块。</p><p>从开发者的角度看,整个系统可能有N个子应用,如果启动整个系统可能会很慢很卡,而产品的某个需求可能只涉及到其中一个子应用,因此开发时只需启动涉及到的子应用即可,独立启动专注开发,因此是很有必要支持子应用的独立开发的。如果要支持,主要会遇到以下几个问题:</p><ul><li><strong>子应用的登录态怎么维护?</strong></li><li><strong>基座不启动时,怎么获取到基座下发的数据和能力?</strong></li></ul><p>在基座运行时,登录态和用户信息是存放在基座上的,然后基座通过props下发给子应用。但如果基座不启动,只是子应用独立启动,子应用就没法通过props获取到所需的用户信息了。因此,解决办法只能是父子应用都得实现一套相同的登录逻辑。为了可复用,可以把登录逻辑封装在common中,然后在子应用独立运行的逻辑中添加登录相关的逻辑。</p><pre><code class="javascript">// 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)
}
// ...</code></pre><p><code>!window.__POWERED_BY_QIANKUN__</code>表示子应用处于非<code>qiankun</code>内的环境,即独立运行时。此时我们依然要注册一个名为<code>global</code>的vuex module,子应用内部同样可以从global module中获取用户的信息,从而做到抹平qiankun和独立运行时的环境差异。</p><blockquote>PS:我们前面写的<code>global-register.js</code>写得很巧妙,能够同时支持两种环境,因此上面可以通过<code>commonStore.globalRegister</code>直接引用。</blockquote><h3>子应用独立仓库</h3><p>随着项目发展,子应用可能会越来越多,如果子应用和基座都集合在同一个git仓库,就会越来越臃肿。</p><p>若项目有CI/CD,只修改了某个子应用的代码,但代码提交会同时触发所有子应用构建,牵一发动全身,是不合理的。</p><p>同时,如果某些业务的子应用的开发是跨部门跨团队的,代码仓库如何分权限管理又是一个问题。</p><p>基于以上问题,我们不得不考虑将各个应用迁移到独立的git仓库。由于我们独立仓库了,项目可能不会再放到同一个目录下,因此前面通过<code>npm i file:../common</code>方式安装的common就不适用了,所以最好还是发布到公司的npm私服或采用git地址形式。</p><blockquote><code>qiankun-example</code>为了更好展示,仍将所有应用都放在同一个git仓库下,请各位同学不要照抄。</blockquote><h3>子应用独立仓库后聚合管理</h3><p>子应用独立git仓库后,可以做到独立启动独立开发了,这时候又会遇到问题:<strong>开发环境都是独立的,无法一览整个应用的全貌</strong>。</p><p>虽然开发时专注于某个子应用时更好,但总有需要整个项目跑起来的时候,比如当多个子应用需要互相依赖跳转时,所以还是要有一个整个项目对所有子应用git仓库的聚合管理才行,该聚合仓库要求做到能够一键install所有的依赖(包括子应用),一键启动整个项目。</p><p>这里主要考虑了三种方案:</p><ol><li>使用<code>git submodule</code>。</li><li>使用<code>git subtree</code>。</li><li>单纯地将所有子仓库放到聚合目录下并<code>.gitignore</code>掉。</li><li>使用lerna管理。</li></ol><p><code>git submodule</code>和<code>git subtree</code>都是很好的子仓库管理方案,但缺点是每次子应用变更后,聚合库还得同步一次变更。</p><p>考虑到并不是所有人都会使用该聚合仓库,子仓库独立开发时往往不会主动同步到聚合库,使用聚合库的同学就得经常做同步的操作,比较耗时耗力,不算特别完美。</p><p>所以第三种方案比较符合笔者目前团队的情况。聚合库相当于是一个空目录,在该目录下clone所有子仓库,并<code>gitignore</code>,子仓库的代码提交都在各自的仓库目录下进行操作,这样聚合库可以避免做同步的操作。</p><p>由于ignore了所有子仓库,聚合库clone下来后,仍是一个空目录,此时我们可以写个脚本<code>scripts/clone-all.sh</code>,把所有子仓库的clone命令都写上:</p><pre><code class="bash"># 子仓库一
git clone git@xxx1.git
# 子仓库二
git clone git@xxx2.git</code></pre><p>然后在聚合库也初始化一个<code>package.json</code>,scripts加上:</p><pre><code class="javascript"> "scripts": {
"clone:all": "bash ./scripts/clone-all.sh",
},</code></pre><p>这样,git clone聚合库下来后,再<code>npm run clone:all</code>就可以做到一键clone所有子仓库了。</p><p>前面说到聚合库要能够做到一键install和一键启动整个项目,我们参考qiankun的examples,使用<a href="https://link.segmentfault.com/?enc=aDT%2FPC1%2BarD4fFxxzR4tFA%3D%3D.d0fQ%2FS00MnaMoLjOx6XkYxEug6VFg2a1coBJ3RvpuxkLmRd2Qo9ec3C2ctjKvw4J" rel="nofollow">npm-run-all</a>来做这个事情。</p><ol><li>聚合库安装<code>npm i npm-run-all -D</code>。</li><li>聚合库的package.json增加install和start命令:</li></ol><pre><code class="javascript"> "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"
},</code></pre><blockquote><code>npm-run-all</code>的<code>--serial</code>表示有顺序地一个个执行,<code>--parallel</code>表示同时并行地运行。</blockquote><p>配好以上,一键安装<code>npm i</code>,一键启动<code>npm start</code>。</p><h4>vscode eslint配置</h4><p>如果使用vscode,且使用了eslint的插件做自动修复,由于项目处于非根目录,eslint没法生效,所以还需要指定eslint的工作目录:</p><pre><code class="javascript">// .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
},
}
</code></pre><h3>子应用互相跳转</h3><p>除了点击页面顶部的菜单切换子应用,我们的需求也要求子应用内部跳其他子应用,这会涉及到顶部菜单active状态的展示问题:<code>sub-vue</code>切换到<code>sub-react</code>,此时顶部菜单需要将<code>sub-react</code>改为激活状态。有两种方案:</p><ul><li>子应用跳转动作向上抛给父应用,由父应用做真正的跳转,从而父应用知道要改变激活状态,有点子组件<code>$emit</code>事件给父组件的意思。</li><li>父应用监听<code>history.pushState</code>事件,当发现路由换了,父应用从而知道要不要改变激活状态。</li></ul><p>由于<code>qiankun</code>暂时没有封装子应用向父应用抛出事件的api,如iframe的<code>postMessage</code>,所以方案一有些难度,不过可以将激活状态放到状态管理中,子应用通过改变vuex中的值让父应用同步就行,做法可行但不太好,维护状态在状态管理中有点复杂了。</p><p>所以我们这里选方案二,子应用跳转是通过<code>history.pushState(null, '/sub-react', '/sub-react')</code>的,因此父应用在mounted时想办法监听到<code>history.pushState</code>就可以了。由于<code>history.popstate</code>只能监听<code>back/forward/go</code>却不能监听<code>history.pushState</code>,所以需要额外全局复写一下<code>history.pushState</code>事件。</p><pre><code class="javascript">// 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()
}
}</code></pre><h2>性能优化</h2><p>每个子应用都是一个完整的应用,每个vue子应用都打包了一份<code>vue/vue-router/vuex</code>。从整个项目的角度,相当于将那些模块打包了多次,会很浪费,所以这里可以进一步去优化性能。</p><p>首先我们能想到的是通过webpack的<code>externals</code>或主应用下发公共模块进行复用。</p><p>但是要注意,如果所有子应用都共用一个相同的模块,从长远来看,不利于子应用的升级,难以两全其美。</p><p>现在觉得比较好的做法是:主应用可以下发一些自身用到的模块,子应用可以优先选择主应用下发的模块,当发现主应用没有时则自己加载;子应用也可以直接使用最新的版本而不用父应用下发的。</p><p>这个方案参考自<a href="https://link.segmentfault.com/?enc=YVrfSsA3AveLk0cV90t9Ow%3D%3D.mFMkOILtPHCpV5UovB%2B0ut7Cu1rNdLrlfZoFRXZ9CoNY4UrUSiftsikVbjYADKTfOsQWRGzdZJHK61AJa%2F9Spw%3D%3D" rel="nofollow">qiankun 微前端方案实践及总结-子项目之间的公共插件如何共享</a>,思路说得非常完整,大家可以看看,本项目暂时还没加上该功能。</p><h2>部署</h2><p>现在网上qiankun部署相关的文章几乎搜不到,可能是觉得简单没啥好说的吧。但对于还不太熟悉的同学来说,其实会比较纠结qiankun部署的最佳部署方案是怎样的呢?所以觉得很有必要讲一下笔者这里的部署方案,供大家参考。</p><p>方案如下:</p><p>考虑到主应用和子应用共用域名时可能会存在路由冲突的问题,子应用可能会源源不断地添加进来,因此我们将子应用都放在<code>xx.com/subapp/</code>这个二级目录下,根路径<code>/</code>留给主应用。</p><p>步骤如下:</p><ol><li>主应用main和所有子应用都打包出一份html,css,js,static,分目录上传到服务器,子应用统一放到<code>subapp</code>目录下,最终如:</li></ol><pre><code class="bash">├── main
│ └── index.html
└── subapp
├── sub-react
│ └── index.html
└── sub-vue
└── index.html</code></pre><ol><li>配置nginx,预期是<code>xx.com</code>根路径指向主应用,<code>xx.com/subapp</code>指向子应用,子应用的配置只需写一份,以后新增子应用也不需要改nginx配置,以下应该是微应用部署的最简洁的一份nginx配置了。</li></ol><pre><code class="bash">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;
}
}</code></pre><p><code>nginx -s reload</code>后就可以了。</p><p>本文特地做了线上demo展示:</p><p>整站(主应用):<a href="https://link.segmentfault.com/?enc=a84cKExbwjjlKtb9rptG6g%3D%3D.HXZDXEjWKs565kcbB5t3Mo%2F359gPskB9uhQQp74tuTA%3D" rel="nofollow">http://qiankun.fengxianqi.com/</a></p><p>单独访问子应用:</p><ul><li><a href="https://link.segmentfault.com/?enc=Ou0MSlu2soUGZqYQainYtQ%3D%3D.kvviGdfwiRv77oXclmymWR4hMJyPBF%2BAvR3DFkCXtqlaVFRb%2BDp3dEvcu5eddctZ" rel="nofollow">subapp/sub-vue</a>, 注意观察vuex数据的变化。</li><li><a href="https://link.segmentfault.com/?enc=ge2IdWleIqnivtTaUc5tfw%3D%3D.rq3tPzvVXB2NSM34O6ynf%2BLqD7oWSJk82yIoMNNpQt%2Fe3cOG%2BT153Zf88j5b4Xbe" rel="nofollow">subapp/sub-react</a></li></ul><h2>遇到的问题</h2><h3>一、react子应用启动后,主应用第一次渲染后会挂掉</h3><p><img src="/img/remote/1460000024551396" alt="image" title="image"><br>子应用的热重载居然会引得父应用直接挂掉,当时完全懵逼了。还好搜到了相关的<a href="https://link.segmentfault.com/?enc=xLhfRkhdmwGGRrNWYSOv3w%3D%3D.MHGuuCHFNejmp0dq9WdzpqcqfI1hrw6PCkYZWjMqKj1VqgarqD%2BGSoO%2FY3SQ%2BeGx" rel="nofollow">issues/340</a>,即在复写react的webpack时禁用掉热重载(加了下面配置禁用后会导致没法热重载,react应用在开发时得手动刷新了,是不是有点难受。。。):</p><pre><code>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;
}
};</code></pre><h3>二、Uncaught Error: application 'xx' died in status SKIP_BECAUSE_BROKEN: [qiankun] Target container with #subapp-viewport not existed while xx mounting!</h3><p>在本地dev开发时是完全正常的,这个问题是部署后在首次打开页面才会出现的,F5刷新后又会正常,只能在清掉缓存后复现一次。这个bug困扰了几天。</p><p>错误信息很清晰,即主应用在挂载xx子应用时,用于装载子应用的dom不存在。所以一开始以为是vue做主应用时,<code>#subapp-viewport</code>还没来得及渲染,因此要尝试确保主应用<code>mount</code>后再注册子应用。</p><pre><code>// 主应用的main.js
new Vue({
render: h => h(App),
mounted: () => {
// mounted后再注册子应用
renderMicroApps();
},
}).$mount('#root-app');</code></pre><p>但该办法不行,甚至setTimeout都用上了也不行,需另想办法。</p><p>最后逐步调试发现是项目加载了一段高德地图的js导致的,该js在首次加载时会使用<code>document.write</code>去复写整个html,因此导致了#subapp-viewport不存在的报错,所以最后是要想办法去掉该js文件就可以了。</p><blockquote>小插曲:为什么我们的项目会加载这个高德地图js?我们项目也没有用到啊,这时我们陷入了一个思维误区:qiankun是阿里的,高德也是阿里的,qiankun不会偷偷在渲染时动态加载高德的js做些数据收集吧?非常惭愧会对一个开源项目有这个想法。。。实际上,是因为我司写组件库模板的小伙伴忘记移除调试时<code>public/index.html</code>用到的这个js了,当时还去评论<a href="https://link.segmentfault.com/?enc=Bg3nDqeSkGFwUnrt73xqJg%3D%3D.SI8BRkfxfh%2BVc4hTQMy3GdZ42GvylBmdhuMFQtdvVa6KgVd6KaXyUPDbXIQxG1U9" rel="nofollow">issue</a>了(捂脸哭)。把这个讲出来,是想说遇到bug时还是要先检查一下自己,别轻易就去质疑别人。</blockquote><h2>最后</h2><p>本文从开始搭建到部署非常完整地分享了整个架构搭建的一些思路和实践,希望能对大家有所帮助。要提醒一下的是,本示例可能不一定最佳的实践,仅作为一个思路参考,架构是会随着业务需求不断调整变化的,只有合适的才是最好的。</p><p>示例代码: <a href="https://link.segmentfault.com/?enc=58K48KkNF1VsznSGDl%2FTNQ%3D%3D.At5eJp3Jf%2FTLTquTtNIKMCc0ScWhbKam1KPuFXktVD6qOV2UkfwtDKeFmK5rSCHt" rel="nofollow">https://github.com/fengxianqi/qiankun-example</a>。</p><p>在线demo:<a href="https://link.segmentfault.com/?enc=mp4pXz0EHQozmEQieSGhRA%3D%3D.4r4GlmTZJ6QBDXGPwXbxWVUDRGVUpxEYW9O%2Ftf3yBPE%3D" rel="nofollow">http://qiankun.fengxianqi.com/</a></p><p>单独访问在线子应用:</p><ul><li><a href="https://link.segmentfault.com/?enc=iqlFXx7XyILPiHZYokdILA%3D%3D.xkT39sNEvdxU1UVOnvn74y%2BENFkmVvAcjWT2%2BUbz2%2FYvnhcYnCU44PCR6dBNQ8HE" rel="nofollow">subapp/sub-vue</a></li><li><a href="https://link.segmentfault.com/?enc=j1gv7wm8odUu6YMOaR64cQ%3D%3D.EicTbkqszU%2Bub5KViDZt4RR6y3zMs4fN%2Bm0A98B43QumhsnLKoACtJuMTAYqH6Si" rel="nofollow">subapp/sub-react</a></li></ul><p>最后的最后,喜欢本文的同学还请能顺手给个赞和小星星鼓励一下,非常感谢看到这里。</p><h2>一些参考文章</h2><ul><li><a href="https://link.segmentfault.com/?enc=VmnhkcKHOJl6Un2xOg7CGA%3D%3D.junNaM14sKyXLGwZusKo1sif5mGlZuVz7luYYUCp62Gy7K5gtAsroJgacBaaNqXQ%2BQMvQLcEJnnNkc7OS7R5%2Bd0Bt41cjt1jLIPx0WB0UAM%3D" rel="nofollow">微前端在小米 CRM 系统的实践</a></li><li><a href="https://link.segmentfault.com/?enc=8VpnCcxKOAH3ceW0FMWTTg%3D%3D.TKuVEZvXpHL40c6pSl%2Fsu8tnjqWbKrWULnzEAJ9AfUEFokisd0VVLrewXnyoCoxw" rel="nofollow">微前端实践</a></li><li><a href="https://link.segmentfault.com/?enc=sa0d9rzTUGSWr3C2s7hzCA%3D%3D.%2BraTvXoTB4ZldfcEp8dzwsTSg9bp5JVzziyZef1HZRuuVuprVg%2B3ZE9k7F7lAKpS" rel="nofollow">可能是你见过最完善的微前端解决方案</a></li><li><a href="https://link.segmentfault.com/?enc=gi9HNEMArYdshxcapg6h8Q%3D%3D.pdxGldERcAYwJdg7L5fMefUIIz0i6zg7RTKKgYI%2BlnqpL%2BbF0ewiHoLXD8%2FULkYIDxBFLClNGiHeFZOOe8OPjn2rUYoxhEB85eraVpCl6wh0px%2B5RGYWWzuq4xPTjWko" rel="nofollow">微前端在美团外卖的实践</a></li><li><a href="https://link.segmentfault.com/?enc=8BSk%2B7l5cHsyBUNiYye8uA%3D%3D.scPLdhsS4rPt3PqISE32pFcDCrVZxF2w5ROQGLBLn4f%2FO%2F72AEHPzbVJO9CjJDCH" rel="nofollow">qiankun 微前端方案实践及总结</a></li></ul>
前端部署和提效:从静态到node再到负载均衡
https://segmentfault.com/a/1190000021630358
2020-01-21T08:00:00+08:00
2020-01-21T08:00:00+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
40
<h2>前言</h2>
<p>相信很多前端同学对 vue 或 react 的开发很熟悉了,也知道如何去打包生成一个生产环境的包,但对于生产环境的部署可能有些同学了解比较少。小公司可能都是后端帮忙部署了,大公司会有专门的运维同学部署,对于生产环境的部署工作有些同学接触的不多,所以这次来分享和总结下前端项目部署相关的实战经验:从静态站点的部署,到 node 项目的部署,再到负载均衡的部署,顺便也会分享一下提高部署效率的脚本和方法。</p>
<h2>准备工作</h2>
<ol>
<li>一台或多台服务器或虚拟机。</li>
<li>一份 vue 或 react 项目的打包后的文件。</li>
<li>一份 node 项目的源码。</li>
</ol>
<h2>静态站点的部署</h2>
<p>静态站点的部署指的是前端的 html/css/js 资源的部署,如 vue 或 react 打包后生成的 html,css 和 js 文件,我们将这些文件上传到服务器后,通过 Nginx 将这些资源暴露到公网上。</p>
<ol><li><strong>上传文件到服务器</strong></li></ol>
<p>就是将文件人工将打包后的文件拷贝到服务器上,这个很简单,但如果每次都是人工拷贝,部署效率未免会低了一些,所以建议使用一些脚本或工具。在大公司,一般对服务器权限控制得很严格,可能需要各种跳板机或动态密码等,而大公司一般都有专门的运维人员或者 CI/CD 工具。</p>
<p>小公司可能相对自由一些,可以允许个人直接 ssh 连接服务器,此时可以配合使用<code>rsync</code>或<code>scp</code>命令(Linux 或 Mac 系统)来一键上传文件到服务器上,给部署提效。在这里分享一下以前使用过的部署脚本,在前端项目根目录新建一个名为<code>deploy.sh</code>的文件:</p>
<pre><code class="bash">#!/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 $@</code></pre>
<p>以上脚本的意思是将<code>/dist</code>目录下的所有文件,上传到对应服务器的<code>/srv/YourProject</code>目录下。测试环境的部署是直接在根目录运行<code>./deploy.sh</code>,该命令会将<code>/dist</code>目录直接上传到<code>root@test_server_ip</code>服务器上;</p>
<p>生产环境的部署是在后面加一个参数<code>./deploy.sh prod</code>,这样可以实现多环境部署。更进一步的做法是将运行脚本的命令直接写进<code>package.json</code>中,如:</p>
<pre><code class="json"> "scripts": {
"build": "vue-cli-service build --mode staging",
"deploy": "npm run build && ./deploy.sh",
"deploy:prod": "npm run build && ./deploy.sh prod"
},</code></pre>
<p>这样,通过<code>npm run deploy</code>命令就可以实现直接打包并部署到测试环境了。如果你的公司目前还在用人工拷贝或 FTP 工具这种低效的部署方式,不妨试一下用上面的脚本来提效哦。</p>
<blockquote>PS:由于 rsync 命令只在 Linux 或 Mac 才有,所以只有开发环境是 Linux 或 Mac 的用户才可以运行哦,Windows 用户是没法跑这个命令的。</blockquote>
<ol><li><strong>编写网站的 conf</strong></li></ol>
<p>上传文件到服务器后,就可以着手配置 nginx 了。一般 nginx 的配置都会放在<code>/etc/nginx/conf.d</code>目录下,我们在该目录新建一个<code>test.conf</code>作为该项目的配置:</p>
<pre><code class="bash">server {
listen 80;
server_name your-domain.com; # 域名
location / {
root /srv/YourProject; # 网站源码根目录
index index.html;
}
location /api {
proxy_pass http://localhost:8080; # 反向代理后端接口地址
}
}</code></pre>
<p>一般来说,静态站点只需配置以上几个就可以了,</p>
<ul>
<li>
<code>server_name</code>表示域名,需要先解析到服务器的公网 ip;</li>
<li>
<code>root</code>表示服务器中代码所在的位置,</li>
<li>
<code>index</code>指明了默认的处理文件是<code>index.html</code>;</li>
<li>
<code>location /api</code>是反向代理后端服务(这里假设了后端服务部署在本地 8080 端口),即<code>your-domain.com/api</code>的请求都会转发到<code>http://localhost:8080</code>上,一般用该方法可以完美解决前端跨域的问题。</li>
</ul>
<p>修改 nginx 的 conf 后需要 reload 一下 nginx 服务:<code>nginx -s reload</code></p>
<ol><li>测试</li></ol>
<p>如果上一步配置的域名是已经解析到服务器 ip 了的,就可以直接在公网上通过访问域名来访问你的站点了。如果不是,可以修改一下本机的 host 文件,使得配置的域名可以在本机访问;或者通过<code>http://localhost</code>来访问。</p>
<h2>node 项目的部署</h2>
<p>node 项目在开发时可以用<code>node app.js</code>这样的命令来启动服务,但在服务器上如果使用这个命令,退出服务器后 node 进程就停止了,所以需要借助可以让 node 进程 keep alive 的工具。现在一般都是用<code>pm2</code>。</p>
<ol><li><strong>安装 pm2</strong></li></ol>
<pre><code>npm install -g pm2</code></pre>
<p>pm2 的一些常用命令:</p>
<pre><code>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</code></pre>
<ol><li><strong>用 pm2 启动项目</strong></li></ol>
<p>一般来说,我们可以直接使用<code>pm2 start app.js --name="my-project"</code>这样的命令来启动 node 项目,但是这样手打的命令会不好管理,所以我们一般会在 node 项目的根目录下新建一个<code>pm2.json</code>文件来指定 pm2 启动时的参数,如:</p>
<pre><code class="javascript">{
"name": "my-project",
"script": "./server/index.js",
"instances": 2,
"cwd": ".",
"exec_mode" : "cluster"
}</code></pre>
<p>name 表示 pm2 进程的名称,script 表示启动的文件入口,instances 表示启动的示例数量(一般建议数值不大于服务器处理器的核数),cmd 表示应用程序所在的目录。</p>
<p>我们在服务器启动 node 项目时就可以直接<code>pm2 start pm2.json</code>。</p>
<ol><li><strong>nginx 代理绑定域名</strong></li></ol>
<p>node 项目使用 pm2 运行后,只是运行在服务器的某个端口,如<code>http://server_ip:3000</code>,如果该服务需要通过域名直接访问,则还需要用 nginx 代理到 80 端口。在<code>/etc/nginx/conf.d</code>新建一个<code>my-project.conf</code>(文件命名随意哈,一般可以用网站域名.conf):</p>
<pre><code class="bash">server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:3000;
}
}</code></pre>
<p>这是最简单的一个配置了,可以根据实际情况加一些参数哈。做完以上的步骤就完成了一个 node 项目的部署啦。</p>
<h2>前端负载均衡部署</h2>
<p>相信很少同学可以接触到负载均衡的部署了,当一个项目的访问量已经大到需要使用负载均衡的时候,一般都会有专门的运维同学去搞了。负载均衡说白了就是将大量的并发请求分担到多个服务器上。</p>
<p>负载均衡的架构有很多种,项目的架构是一个不断演进的过程,采用哪种负载均衡的架构需要具体问题具体分析,所以本文不会讲什么时候适合用哪种架构(笔者也不会,笑),接下来将会分享实战如何用 Nginx 从零搭建一个经典的负载均衡架构案例。</p>
<p><img src="/img/remote/1460000021630361" alt="" title=""><br><code>Nginx Server</code>是直接暴露在最前端的机器,当用户发起请求,首先到达的是<code>Nginx</code>服务器,然后<code>Nginx</code>服务器再将请求通过某种算法分发到各个二级服务器上(图中的<code>Centos2</code>,<code>Centos3</code>,<code>Centos4</code>),此时<code>Nginx Server</code>充当的就是一个负载均衡的机器(Load Balancer)。</p>
<p>笔者手上没有这么多的服务器,为了更完整地演示,所以现在借助 VirtualBox 建立四个虚拟机来模拟四个服务器(当然条件确实限制时可以用同一个服务器的四个端口来代替)。</p>
<p><img src="/img/bVbCVbE" alt="WechatIMG160.png" title="WechatIMG160.png"><br>笔者新建了四个 Centos8 系统的虚拟机,用以假设 4 台服务器,对外都有独立 ip(假设分别是 192.168.0.<code>1</code>,<code>2</code>,<code>3</code>,<code>4</code>)。如前面的架构图所示,Centos1 将会是作为<code>Nginx Server</code>,充当最前端的负载均衡服务器,而其余的<code>Centos2</code>,<code>Centos3</code>,<code>Centos4</code>作为应用服务器,为用户提供真正的服务。接下来咱们一步一步去搭建这个系统。</p>
<h3>一、应用服务器搭建服务站点</h3>
<p>万丈高楼平地起,咱们首先得先搭建一个能对外的服务,这个服务可以是一个网站也可以是一个接口。为了简单起见,我们就直接起一个<code>koa</code>的<code>Hello World</code>,同时为了后面验证负载均衡的效果,每台机器上部署的代码都稍微改一下文案,如:<code>Hello Centos2</code>,<code>Hello Centos3</code>,<code>Hello Centos4</code>,这样方便后面验证用户的请求是被分发到了哪一台服务器。</p>
<p>koa 的 demo 站点已经为大家准备好了:<a href="https://link.segmentfault.com/?enc=x7fEND3m5RKd3tLlwjNK6g%3D%3D.tK13DWxyuzmQLLIqjvZanks%2F4Drs%2BNeAEGFf4DsY5E1vsb5eG%2BvTF0CxZ29LgIgJEntUE0jR4MfbKa8LXPdYAAU0z2V8n2I5k3YOqmxe1BE%3D" rel="nofollow">koa-loadbalance</a>。</p>
<p>我们这里以<code>Centos2(192.168.0.2)</code>(ip 是虚构的)这台虚拟机为例,将会用<code>pm2</code>部署 koa 站点在该虚拟机上。</p>
<ol><li><strong>通过 scp 或 rsync 命令将源码上传到 Centos2 服务器</strong></li></ol>
<p>还记得上面的<code>deploy.sh</code>脚本吗?如果你添加了脚本在项目中,就可以<code>npm run deploy</code>直接部署到服务器上了。demo 源码中有这个脚本,大家可以改一下里面实际的 ip,再执行命令哈。</p>
<ol><li><strong>ssh 进入 Centos2 服务器</strong></li></ol>
<pre><code>ssh root@192.168.0.2</code></pre>
<ol><li><strong>安装 node 环境</strong></li></ol>
<pre><code>curl -sL https://rpm.nodesource.com/setup_13.x | sudo bash -
sudo yum install nodejs</code></pre>
<p>可以在<a href="https://link.segmentfault.com/?enc=l7dA12L7dcoFx6doXYoUNQ%3D%3D.K9b%2BBQqyIZBiXxe6n00zfnTIIzv1HS9j1XWwJtVZRDqTTP%2F65tsBIOjPbjP2Nc95wIoNkPG8%2B4u3p0KRVOkNMA%3D%3D" rel="nofollow">这里</a>看当前 node 有哪些版本,选最新的就行,现在是 13。</p>
<ol><li><strong>安装 pm2</strong></li></ol>
<pre><code>npm i pm2 -g</code></pre>
<ol><li><strong>pm2 启动站点</strong></li></ol>
<p>在项目根目录执行:</p>
<pre><code>pm2 start pm2.json</code></pre>
<p><code>pm2 list</code>检查一下项目启动情况 ,同时用<code>curl localhost:3000</code>看返回值:</p>
<p><img src="/img/bVbCVbl" alt="WechatIMG165.png" title="WechatIMG165.png"></p>
<p>同理,按上面步骤给<code>Centos3</code>和<code>Centos4</code>服务器都将服务部署起来。(记得改一下<code>index.js</code>中的<code>Hello XXX</code>方便后面验证)。不出意外的话,我们的网站就分别运行在三台服务器的 3000 端口了:</p>
<ul>
<li>
<code>192.168.0.2:3000</code> ==> <code>Hello Centos2</code>
</li>
<li>
<code>192.168.0.3:3000</code> ==> <code>Hello Centos3</code>
</li>
<li>
<code>192.168.0.4:3000</code> ==> <code>Hello Centos4</code>
</li>
</ul>
<blockquote>有同学可能会问,为什么 Centos2,Centos3,Centos4 不用装 Nginx 的?在实际操作中,应用服务器其实是不用暴露在公网上的,它们与负载均衡服务器只需通过内网直接连接就可以了,这样更安全;而我们的站点又是 Node 项目,本身就可以提供 Web 服务,所以不用再装一个 Nginx 进行代理或转发了。</blockquote>
<h3>二、搭建 Nginx Server</h3>
<p>nginx 的安装方法:<a href="https://link.segmentfault.com/?enc=5%2Bjm9nSIR8DdBCDlOwCTEA%3D%3D.Ch6RK%2FWzJdY%2F4W8K4lRsRMLCWL9nxEeskY82NZSs5iZK0iAkw%2B0DooYBfAruxO8%2FxVwV4lW4%2F1KwNd1Rg1pVxQ%3D%3D" rel="nofollow">Nginx Install</a>。</p>
<p>在<code>Centos1</code>安装好 nginx 就可以了。</p>
<h3>三、实现负载均衡</h3>
<p>一开始还没了解过负载均衡时可能会觉得很难完全不知道是怎么配的,然后接下来你会发现超级简单,因为只需要 nginx 一个配置就可以了:<code>upstream</code>。</p>
<ul><li><strong>集群所有节点</strong></li></ul>
<p>我们将上面已经部署好的<code>Centos2</code>,<code>Centos3</code>,<code>Centos4</code>集群起来,nginx 配置类似下面这样:</p>
<pre><code>upstream APPNAME {
server host1:port;
server host2:port;
}</code></pre>
<p><code>APPNAME</code>可以自定义,一般是项目名。在<code>/etc/nginx/conf.d</code>新建一个<code>upstream.conf</code>:</p>
<pre><code>upstream koa-loadbalance {
server 192.168.0.2:3000;
server 192.168.0.3:3000;
server 192.168.0.4:3000;
}</code></pre>
<p>这样,我们已经将三台服务器集成为了<code>http://koa-loadbalance</code>的一个集群,下一步会使用它。</p>
<ul><li><strong>配置对外的站点</strong></li></ul>
<p>接下来是配置一个面向用户的网站了,我们假设网站会使用<code>www.a.com</code>这个域名,在<code>/etc/nginx/conf.d</code>下新建<code>a.com.conf</code>:</p>
<pre><code>server {
listen 80;
server_name www.a.com;
charset utf-8;
location / {
proxy_pass http://koa-loadbalance; # 这里是上面集群的名称
}
}</code></pre>
<p>配置结束后记得<code>nginx -s reload</code>重启一下,这样就完成负载均衡的配置了。</p>
<h3>四、测试</h3>
<p>如果你的域名是真实的且已经解析到 nginx 服务器,则此时可以直接通过域名访问了。由于笔者这里用的是虚拟机,公网不可访问,所以这里配置一下宿主机的 host,使得<code>www.a.com</code>指向<code>centos1</code>服务器,然后在浏览器打开<code>www.a.com</code>就可以测试我们的负载均衡网站啦。Mac 系统上是<code>sudo vi /etc/hosts</code>,在最后面添加一行:</p>
<pre><code># IP是Centos1的ip
192.168.0.1 www.a.com</code></pre>
<p><img src="/img/remote/1460000021630362" alt="image" title="image"></p>
<p>我们在浏览器访问<code>www.a.com</code>,可以看到随着不断刷新,服务器返回了不同的<code>Hello CentosX</code>,说明我们的请求被分发到三台服务器上了,负载均衡的配置生效啦。</p>
<h3>五、负载均衡的几种策略</h3>
<p>nginx 的 upstream 可以设置很多种负载均衡的策略,以下介绍几个常用的策略。</p>
<ol><li>轮询(默认):每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器 down 掉,能自动剔除。</li></ol>
<pre><code>upstream test {
server 192.168.0.2:3000;
server 192.168.0.3:3000;
server 192.168.0.4:3000;
}</code></pre>
<ol><li>指定权重 weight:指定轮询几率,weight 和访问比率成正比,用于后端服务器性能不均的情况。</li></ol>
<pre><code>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;
}</code></pre>
<ol><li>ip_hash:每个请求按访问 ip 的 hash 结果分配,这样每个访客固定访问一个后端服务器,可以解决 session 的问题。</li></ol>
<pre><code>upstream test {
ip_hash;
server 192.168.0.2:3000;
server 192.168.0.3:3000;
server 192.168.0.4:3000;
}</code></pre>
<ol><li>fair(第三方):按后端服务器的响应时间来分配请求,响应时间短的优先分配。</li></ol>
<pre><code>upstream test {
server 192.168.0.2:3000;
server 192.168.0.3:3000;
server 192.168.0.4:3000;
fair;
}</code></pre>
<ol><li>url_hash(第三方):按访问 url 的 hash 结果来分配请求,使每个 url 定向到同一个(对应的)后端服务器,后端服务器为缓存时比较有效。</li></ol>
<pre><code>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;
}</code></pre>
<p>更多的策略请参考:<a href="https://link.segmentfault.com/?enc=rVdPDmeJPTW5QxxRg9ipUg%3D%3D.2lqvGlkUPwAgvNqSkQu7Rn4SMOEj9%2FQudjt1qjW74i%2BrcGDCb46zKM2QyMBMBkXts%2FEAqkrkHuuocUyaA96qXxoW2BF7rU112j0PoElWRso%3D" rel="nofollow">ngx_http_upstream_module</a>,根据实际情况使用上面的这些策略,没有特别需求就使用默认的轮询方式也可以。</p>
<h2>最后</h2>
<p>从静态站点到 node 站点,再到负载均衡,相信看完本文大家对整个前端的部署体系都有了一个比较全面的了解。特别是负载均衡,平时接触得少总觉得特别复杂,其实看完了会觉得很简单。更高级一些的部署可能会用上 Docker 或 k8s 的集群了,这个就留待后面再说啦。</p>
<p>对于部署方式的提效,本文也分享了一个使用<code>rsync</code>命令的脚步,配合<code>package.json</code>的 script,可以做到一个命令就完成部署的动作。</p>
<p>当然,该做法也还是有很大的优化空间的,真正好用的部署方式应该是持续集成,通过 Jenkins 或其他工具实现自动化部署,代码 push 上去就自动构建和部署了。如果你的公司还在用最原始的部署方式,不妨加把劲多探索一些这些更爽更溜的操作啦。</p>
Centos8使用docker迁移typecho博客
https://segmentfault.com/a/1190000021390958
2019-12-26T08:00:00+08:00
2019-12-26T08:00:00+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
1
<blockquote>最近在学docker,先拿自己的博客来开下刀[手动狗头]。</blockquote>
<h2>安装docker</h2>
<p>我是根据这个教程来安装的:<a href="https://link.segmentfault.com/?enc=PUmFIKyIrU8jl5CH2%2Ff8Vg%3D%3D.duATBbFlMhTSsHx06s1v%2BGZS7UWNboP9WvQfFd7pR5Yqqb2fgNzac78YIJQzVMCgwru81iqA73bUVOe%2F4K%2BiYQ%3D%3D" rel="nofollow">Centos安装Docker</a>。步骤如下:</p>
<ol><li>卸载旧版本</li></ol>
<pre><code>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</code></pre>
<ol><li>安装依赖包</li></ol>
<pre><code>sudo yum install -y yum-utils \
device-mapper-persistent-data \
lvm2</code></pre>
<ol><li>添加yum源(建议使用国内镜像)</li></ol>
<pre><code>sudo yum-config-manager \
--add-repo \
https://mirrors.ustc.edu.cn/docker-ce/linux/centos/docker-ce.repo</code></pre>
<ol><li>更新软件源缓存并安装docker-ce</li></ol>
<pre><code>sudo yum makecache fast
sudo yum install docker-ce</code></pre>
<p><code>sudo yum install docker-ce</code>这一步可能会报错:<code>containerd.io (>= 1.2.2-3)</code>,此时需要先安装新版本的<code>containerd.io</code></p>
<pre><code>yum install -y https://download.docker.com/linux/centos/7/x86_64/stable/Packages/containerd.io-1.2.6-3.3.el7.x86_64.rpm</code></pre>
<p>最新的containerd.io版本请在<a href="https://link.segmentfault.com/?enc=dVrjiirIimgfZfahEiuNKg%3D%3D.Av3KEYvgbYZHE7jqOix9RTrlD38tf5OkMjCzuSwlRN%2Bwz6tNYAVH5JLbrlm3tPAiLQ33br7VOLBhPe74BQqo9lOwGuvZoz2bJTCs2II%2BO2Y%3D" rel="nofollow">https://download.docker.com/linux/centos/7/x86_64/stable/Packages/</a>找(国内直接安装containerd.io可能会非常慢导致安装失败,建议可以想办法将rpm包下载到本机再安装)。安装完containerd.io后再<code>sudo yum install docker-ce</code>即可。</p>
<ol><li>启动Docker CE</li></ol>
<pre><code>sudo systemctl enable docker
sudo systemctl start docker</code></pre>
<ol><li>测试Docker是否安装正确</li></ol>
<pre><code>docker run hello-world</code></pre>
<ol><li>配置国内镜像加速</li></ol>
<p><code>vi /etc/docker/daemon.json</code>(该文件不存在时请新建)</p>
<pre><code>{
"registry-mirrors": [
"https://dockerhub.azk8s.cn",
"https://reg-mirror.qiniu.com"
]
}</code></pre>
<p>保存后重启服务</p>
<pre><code>sudo systemctl daemon-reload
sudo systemctl restart docker</code></pre>
<h2>安装mariadb</h2>
<pre><code>mkdir -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</code></pre>
<blockquote>
<code>--mount</code>命令也可以用<code>-v /data/mariadb:/var/lib/mysql --name mariadb mariadb</code>代替,官方更推荐使用<code>--mount</code>,详情请看<a href="https://link.segmentfault.com/?enc=1THo0Nbcx8DMTLxi3uAndQ%3D%3D.0anuXjYgLc4ZP%2F7%2BXNUS5Tj1gU0e3tGPAE4N9LKUJzaS2NLyZg0IByXsOgwzKXB1" rel="nofollow">官网</a>。</blockquote>
<p>参数说明:</p>
<ul>
<li>-d 代表 daemon,即后台运行</li>
<li>-p是映射容器的3306端口到宿主机的3306端口,规则是:<code>-p IP:host_port:container_port</code>
</li>
<li>-e是设置mariadb的密码</li>
<li>--mount是让容器的<code>/var/lib/mysql</code>映射到宿主机的<code>/data/mariadb</code>目录中</li>
<li>--restart=always是为了在docker重启时,容器能够自动启动</li>
</ul>
<blockquote>PS: 后面笔者会将所有容器依赖的一些数据都放在<code>/data</code>目录下,包括数据库、网站源码、nginx的conf等,目的是为了以后迁移方便,直接将<code>/data</code>拷贝到新服务器就可以。</blockquote>
<h2>安装nginx</h2>
<pre><code>mkdir -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</code></pre>
<p>说明:</p>
<ul>
<li>
<code>--mount type=bind,source=/data/nginx/conf.d,target=/etc/nginx/conf.d</code>是为了将nginx的配置目录映射到宿主机的目录中,这样当nginx容器被销毁时,依然能保留配置。</li>
<li>
<code>/data/solution</code>用于存放网站项目,也是为了数据与容器分离。</li>
<li>-d这里同时映射了80和443端口</li>
</ul>
<h2>安装php</h2>
<p>可以在php的<a href="https://link.segmentfault.com/?enc=2oaaqEUiZpiZFlJWw8sxvQ%3D%3D.SdhhiGV9UOcUQctPdXTt5XU41NVo%2BlQ9MUHByYNWfOc%3D" rel="nofollow">官方镜像源</a>找到最新版本的php,在实际使用中,我们可能还需要装一些php的扩展,而官方源中支持已经帮我们安装了一些扩展的php镜像,如:<code>php:<version>-fpm</code>,其中的<code><version></code>指的是php版本,具体可以从<a href="https://link.segmentfault.com/?enc=hDIKlbDMDVic6I2bB2GRAg%3D%3D.urEFOkSSpvwLRgmcEA9d4Pnd%2BzwdBNarllAUxLZ2rqU%3D" rel="nofollow">官方镜像源</a>找到,当前最高版本是7.4。</p>
<pre><code>docker run --name php-fpm -p 9000:9000 --mount type=bind,source=/data/solution,target=/data/solution --restart=always -d php:7.4-fpm</code></pre>
<p>这里也将网站根目录<code>/data/solution</code>映射到php容器中,为了php能正确读取nginx中的root配置项。</p>
<p>由于typecho需要用到<code>pdo_mysql</code>扩展,因此要在<code>php-fpm</code>上安装这个扩展。</p>
<pre><code># 进入到`php-fpm`容器内部
docker exec -it php-fpm bash
# 安装扩展
docker-php-ext-install pdo_mysql
# 查看是否已经成功安装
php -m
# 退出容器
exit</code></pre>
<h2>容器与容器通信</h2>
<p>现在机器上已经运行着<code>nginx</code>、<code>PHP</code>、<code>mariadb</code>三个服务了,他们分别跑在宿主机的<code>80(443)</code>、<code>9000</code>、<code>3306</code>端口上。我们现在需要做到的是让nginx能够使用php的服务,php能够调用mariadb的服务,但容器间默认是互相隔离的,没法直接通信,因此需要想办法让他们能够互相通信。容器间的通信初次接触会有点复杂,所以在这里先和大家分享下一些要点。</p>
<p>通过了解,容器间通信主要会有以下几种方法:</p>
<ol>
<li>使用默认的<code>bridge</code>网络,用网桥给容器分配的ip进行通信,官方不推荐用于生产环境。</li>
<li>自定义<code>bridge</code>网络,可以通过容器名连接,<strong>官方推荐</strong>。</li>
<li>使用<code>host</code>网络共用宿主机的网络。</li>
</ol>
<blockquote>很多早期的文章分享都会使用<code>--link</code>参数来指定容器通信,但<a href="https://link.segmentfault.com/?enc=8JKbwJFBFvXIvKq0w1c5HQ%3D%3D.gPtJOX2o188ZBfIhvRtv3%2Byy8z%2B1uO%2BAgcnwpgH9rP2TB3nUZZ4QjBAto7%2BNdlBW" rel="nofollow">这种方法</a>已经是过时的了,官方不再推荐使用,所以咱们这里也就不再用了,用官方推荐的方法更好。</blockquote>
<h3>使用默认的bridge网络</h3>
<p>启动容器时,docker默认会将自动将容器绑定到默认的<code>bridge</code>网络中。打印一下默认的网络:</p>
<pre><code>$ docker network ls
NETWORK ID NAME DRIVER SCOPE
17e324f45964 bridge bridge local
6ed54d316334 host host local
7092879f2cc8 none null local</code></pre>
<p>然后再看一下都有哪些容器连接到了<code>bridge</code>网络:</p>
<pre><code>docker network inspect bridge</code></pre>
<p><img src="/img/bVbBUVB" alt="微信图片_20191208225610.png" title="微信图片_20191208225610.png"><br>可以看到mariadb,nginx,php-fpm的在bridge中的ip分别是<code>172.17.0.4</code>,<code>172.17.0.2</code>,<code>172.17.0.3</code>,宿主机的ip是<code>172.17.0.1</code>。</p>
<p>我们可以通过在容器中通过宿主机的ip来访问对应的服务,即php-fpm想要访问mariadb,可以在php-fpm容器中通过<code>172.17.0.1:3306</code>来访问。</p>
<p>这种方式只能使用ip来访问对应的容器的服务,而ip可能会变化的,因此是不推荐使用在生产环境的,所以我们不会使用这种方式。</p>
<h3>使用自定义bridge网络</h3>
<p>除了默认的<code>bridge</code>网络,官方推荐用户自定义一个<code>bridge</code>网络用作生产环境,用户自定义的bridge网络不仅支持ip访问,<strong>还支持直接使用容器名称进行访问</strong>,官方推荐使用在生产环境,因此我们会使用这种方式进行容器间的通信。</p>
<ol><li>创建一个自定义网络,名字可以随意,如<code>typecho</code>。</li></ol>
<pre><code>docker network create typecho</code></pre>
<p>打印一下当前的network,可以看到<code>typecho</code>已经存在了。</p>
<pre><code>$ docker network ls
NETWORK ID NAME DRIVER SCOPE
429eabf557a3 bridge bridge local
3f47e77c0ded host host local
3784484fb92e typecho bridge local
7ff63bc6d9bd none null local</code></pre>
<ol><li>容器使用自定义的网络</li></ol>
<p>如果容器尚未创建,可以在<code>docker run</code>命令时通过<code>--network</code>参数来指定网络,如</p>
<pre><code>docker run -d --name mynginx --network typecho nginx</code></pre>
<p>如果容器已经在运行了,我们也可以通过<code>docker network connect ${网络名} ${容器名}</code>来指定,在上面我们已经运行了<code>mariadb</code>,<code>php-fpm</code>,<code>nginx</code>,所以现在依次为他们都绑定到<code>typecho</code>网络中。</p>
<pre><code>docker network connect typecho mariadb
docker network connect typecho php-fpm
docker network connect typecho nginx</code></pre>
<p>检查一下绑定到<code>typecho</code>网络的容器</p>
<pre><code>$ 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": {}
}
]</code></pre>
<p>可以看到<code>mariadb</code>,<code>php-fpm</code>,<code>nginx</code>都已经连接到<code>typecho</code>网络了。这样,我们在<code>nginx</code>容器,就可以直接通过<code>php-fpm</code>的名字来调用php的服务了。</p>
<h3>使用host网络</h3>
<p>如果容器使用了<code>host</code>网络,即docker运行时通过<code>--network host</code>来指定容器的网络,会使得容器共享宿主机的网络配置,即容器的localhost就是宿主机的localhost。</p>
<p>直接使用Docker host网络最大的好处就是性能更好,如果容器对网络传输效率有较高要求,则可以选择host网络。当然不便之处就是牺牲一些灵活性,比如要考虑端口冲突问题,Docker host上已经使用的端口就不能再用了。</p>
<p>我们这里暂时不使用这种方式。</p>
<h2>迁移typecho博客</h2>
<h3>迁移数据库</h3>
<p>由于是第一次迁移,新旧两个数据库的配置可能会有些不一样,所以为了不改变新服务器的一些配置,我们只迁移mariadb中涉及到数据的部分。</p>
<p>具体操作就是: 将原服务器<code>/var/lib/mysql</code>中数据库相关的文件夹提取出来,如旧服务器有两个名为<code>typecho</code>和<code>test</code>的数据库,就只将这两个文件夹复制到新服务器的<code>/data/mariadb</code>中,然后重启一下mariadb即可:<code>docker restart mariadb</code>。</p>
<blockquote>PS: 还有一种迁移办法是可以选择将旧服务器的数据库备份成sql文件,然后在新服务器做还原哦。</blockquote>
<h3>迁移typecho源码</h3>
<p>笔者这里将typecho的源代码放到<code>/data/solution/typecho</code>文件夹中。</p>
<h3>配置nginx</h3>
<p><code>vi /data/nginx/conf.d/typecho.conf</code></p>
<pre><code>server {
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;
}
}</code></pre>
<p>server_name我这里写了localhost,暂时算是本地测试一下,实际使用时会改成域名。</p>
<p>修改nginxi配置后需要重启一下nginx的镜像才能生效:<code>docker restart nginx</code>。</p>
<h3>typecho的配置文件<code>config.inc.php</code>
</h3>
<p>注意数据库连接的配置,数据库的host可以直接使用<code>mariadb</code>,因为前面将两个容器绑在了同一个网络,php能正常解析到。</p>
<pre><code>/** .... */
/** 定义数据库参数 */
$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);</code></pre>
<p>至此,整个迁移过程就完成了,可以用<code>curl localhost</code>测试一下或在浏览器中直接使用服务器的ip来访问页面啦。</p>
<blockquote>PS: 记得不要忘了要打开服务器的80端口防火墙哦~~<p>Tips: 如果没法正常启动typecho,可能是某一步配置的不对,可以通过<code>docker logs <container_name></code>命令查看对应容器的日志,然后查找相关的解决办法哦。附上两篇typecho解决错误的文章:<a href="https://link.segmentfault.com/?enc=GNrFaGcru6XPqwRhmP7M9w%3D%3D.iQkhQKDUwAFoO35sU3E880kWPYIKemF0e%2B0tmiEAHt9VI%2FHXNI1htZdyRUQHxbdu" rel="nofollow">Typecho 部署踩坑</a>, <a href="https://link.segmentfault.com/?enc=kSDhBfOQf8KHtuH2UqyyGg%3D%3D.bkWHDb1H8otw7nUDsD3Sl2ljIHyC72aU5XAwuv4xb9srxn12PDxpvGStFxdaRV%2Bj" rel="nofollow">安装Typecho卡在“确认您的配置,数据库配置”问题的终极解决方法</a></p>
</blockquote>
<h2>总结</h2>
<p>用上docker最爽的一点就是不用再关心怎么安装软件的过程了,整个过程十分清爽。在整个过程中有几点可以留意一下:</p>
<ol>
<li>笔者将所有docker依赖的数据都挂载到宿主机的<code>/data</code>目录中,是为了方便管理和以后迁移;</li>
<li>每个服务都是一个独立的容器,不会互相影响。如mariadb除了可以为我的博客系统服务,也可以给其他的服务调用;nginx也支持多站点,只需要<code>/data/nginx/conf.d</code>增加一个配置。</li>
</ol>
<p>由于是第一次用上docker迁移,第一次会有点折腾,但以后再迁移就会方便多了。整个流程大概会是这样子:</p>
<pre><code># 在新服务器拉取旧服务器的数据
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</code></pre>
如何在前端中使用protobuf(node篇)
https://segmentfault.com/a/1190000021052359
2019-11-21T08:00:00+08:00
2019-11-21T08:00:00+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
7
<p><img src="/img/bVbAuQs" alt="images.png" title="images.png"></p>
<blockquote>前段时间分享了一篇:<a href="https://segmentfault.com/a/1190000021052292">如何在前端中使用protobuf(vue篇)</a>,一直懒癌发作把node篇拖到了现在。上次分享中很多同学就"前端为什么要用protobuf"展开了一些讨论,表示前端不适合用<code>protobuf</code>。我司是ios、android、web几个端都一起用了protobuf,我也在之前的分享中讲了其中的一些收益和好处。如果你们公司也用到,或者以后可能用到,我的这两篇分享或许能给你一些启发。</blockquote>
<h2>解析思路</h2>
<p>同样是要使用<a href="https://link.segmentfault.com/?enc=0lenCWg0HFDsNMOtl%2B5XJg%3D%3D.TGbVY7dr6q8wrXL0CZbMBgPslT%2F1odMfg7RNTEb24GkvIOH%2B7%2Bbrryl6ZWSYGIN6" rel="nofollow">protobuf.js</a>这个库来解析。</p>
<p>之前提到,在vue中,为了避免直接使用<code>.proto</code>文件,需要将所有的<code>.proto</code>打包成<code>.js</code>来使用。</p>
<p>而在node端,也可以打包成js文件来处理。但node端是服务端环境了,完全可以允许<code>.proto</code>的存在,所以其实我们可以有优雅的使用方式:直接解析。</p>
<h2>预期效果</h2>
<p>封装两个基础模块:</p>
<ul>
<li>
<code>request.js</code>: 用于根据接口名称、请求体、返回值类型,发起请求。</li>
<li>
<code>proto.js</code>用于解析proto,将数据转换为二进制。</li>
</ul>
<p>在项目中可以这样使用:</p>
<pre><code>// lib/api.js 封装API
const request = require('./request')
const proto = require('./proto')
/**
*
* @param {* 请求数据} params
* getStudentList 是接口名称
* school.PBStudentListRsp 是定义好的返回model
* school.PBStudentListReq 是定义好的请求体model
*/
exports.getStudentList = function getStudentList (params) {
const req = proto.create('school.PBStudentListReq', params)
return request('school.getStudentList', req, 'school.PBStudentListRsp')
}
// 项目中使用lib/api.js
const api = require('../lib/api')
const req = {
limit: 20,
offset: 0
}
api.getStudentList(req).then((res) => {
console.log(res)
}).catch(() => {
// ...
})</code></pre>
<h2>准备工作:</h2>
<p>准备<a href="https://link.segmentfault.com/?enc=pSp5iNwIpbygharpQNhA1g%3D%3D.ICXS2RN9FohBBJ0rdL2FSDe%2BCNXAF246SdmFj4mF1CGTtt4QCoWhwS51VtZQz9VDyalS1s8jJt6zijIZtKni%2BQ%3D%3D" rel="nofollow">如何在前端中使用protobuf(vue篇)</a>中定义好的一份<code>.proto</code>,注意这份proto中定义了两个命名空间:<code>framework</code>和<code>school</code>。<a href="https://link.segmentfault.com/?enc=uUq9nltRC2Vs8JTo%2Bk3egQ%3D%3D.f5xOn9JLC7sRxSOgazzWfvlXJO91gk2oP8ouqlV44sPq%2FZfuxaE%2Bow3ixJ0MzEnk4%2F41MShxwKJrwnyjrPgCRY01gVexH%2By%2BLQtFI3iAsD0Aiyq7eJ1NynaUuAc2u4OP" rel="nofollow">proto文件源码</a></p>
<h2>封装proto.js</h2>
<p>参考下官方文档将object转化为buffer的方法:</p>
<pre><code>protobuf.load("awesome.proto", function(err, root) {
if (err)
throw err;
var AwesomeMessage = root.lookupType("awesomepackage.AwesomeMessage");
var payload = { awesomeField: "AwesomeString" };
var message = AwesomeMessage.create(payload);
var buffer = AwesomeMessage.encode(message).finish();
});</code></pre>
<p>应该比较容易理解:先load <code>awesome.proto</code>,然后将数据<code>payload</code>转变成我们想要的<code>buffer</code>。<code>create</code>和<code>encode</code>都是protobufjs提供的方法。</p>
<p>如果我们的项目中只有一个<code>.proto</code>文件,我们完全可以像官方文档这样用。<br>但是在实际项目中,往往是有很多个<code>.proto</code>文件的,如果每个PBMessage都要先知道在哪个<code>.proto</code>文件中,使用起来会比较麻烦,所以需要封装一下。<br>服务端同学给我们的接口枚举中一般是这样的:</p>
<pre><code>getStudentList = 0; // 获取所有学生的列表, PBStudentListReq => PBStudentListRsp</code></pre>
<p>这里只告诉了这个接口的请求体是<code>PBStudentListReq</code>,返回值是<code>PBStudentListRsp</code>,而它们所在的<code>.proto</code>文件是不知道的。</p>
<p>为了使用方便,我们希望封装一个方法,形如:</p>
<pre><code>const reqBuffer = proto.create('school.PBStudentListReq', dataObj) </code></pre>
<p>我们使用时只需要以<code>PBStudentListReq</code>和<code>dataObj</code>作为参数即可,无需关心<code>PBStudentListReq</code>是在哪个<code>.proto</code>文件中。<br>这里有个难点:<strong>如何根据类型来找到所在的<code>.proto</code>呢?</strong></p>
<p>方法是:把所有的<code>.proto</code>放进内存中,然后根据名称获取对应的类型。</p>
<p>写一个loadProtoDir方法,把所有的proto保存在<code>_proto</code>变量中。</p>
<pre><code>// proto.js
const fs = require('fs')
const path = require('path')
const ProtoBuf = require('protobufjs')
let _proto = null
// 将所有的.proto存放在_proto中
function loadProtoDir (dirPath) {
const files = fs.readdirSync(dirPath)
const protoFiles = files
.filter(fileName => fileName.endsWith('.proto'))
.map(fileName => path.join(dirPath, fileName))
_proto = ProtoBuf.loadSync(protoFiles).nested
return _proto
}</code></pre>
<p><code>_proto</code>类似一颗树,我们可以遍历这棵树找到具体的类型,也可以通过其他方法直接获取,比如<code>lodash.get()</code>方法,它支持<code>obj['xx.xx.xx']</code>这样的形式来取值。</p>
<pre><code>const _ = require('lodash')
const PBMessage = _.get(_proto, 'school.PBStudentListReq')</code></pre>
<p>这样我们就拿到了顺利地根据类型在所有的proto获取到了<code>PBMessage</code>,<code>PBMessage</code>中会有protobuf.js提供的<code>create</code>、<code>encode</code>等方法,我们可以直接利用并将object转成buffer。</p>
<pre><code>const reqData = {a: '1'}
const message = PBMessage.create(reqData)
const reqBuffer = PBMessage.encode(message).finish()</code></pre>
<p>整理一下,为了后面使用方便,封装成三个函数:</p>
<pre><code>let _proto = null
// 将所有的.proto存放在_proto中
function loadProtoDir (dirPath) {
const files = fs.readdirSync(dirPath)
const protoFiles = files
.filter(fileName => fileName.endsWith('.proto'))
.map(fileName => path.join(dirPath, fileName))
_proto = ProtoBuf.loadSync(protoFiles).nested
return _proto
}
// 根据typeName获取PBMessage
function lookup (typeName) {
if (!_.isString(typeName)) {
throw new TypeError('typeName must be a string')
}
if (!_proto) {
throw new TypeError('Please load proto before lookup')
}
return _.get(_proto, typeName)
}
function create (protoName, obj) {
// 根据protoName找到对应的message
const model = lookup(protoName)
if (!model) {
throw new TypeError(`${protoName} not found, please check it again`)
}
const req = model.create(obj)
return model.encode(req).finish()
}
module.exports = {
lookup, // 这个方法将在request中会用到
create,
loadProtoDir
}</code></pre>
<p>这里要求,在使用<code>create</code>和<code>lookup</code>前,需要先<code>loadProtoDir</code>,将所有的proto都放进内存。</p>
<h2>封装request.js</h2>
<p>这里要建议先看一下<a href="https://link.segmentfault.com/?enc=ac5swuxymVXmkb%2FlJIURJg%3D%3D.rnBOpcZRCSGzA0f%2Fu1FrXQ%3D%3D" rel="nofollow"><code>MessageType.proto</code></a>,其中定义了与后端约定的接口枚举、请求体、响应体。</p>
<pre><code>const rp = require('request-promise')
const proto = require('./proto.js') // 上面我们封装好的proto.js
/**
*
* @param {* 接口名称} msgType
* @param {* proto.create()后的buffer} requestBody
* @param {* 返回类型} responseType
*/
function request (msgType, requestBody, responseType) {
// 得到api的枚举值
const _msgType = proto.lookup('framework.PBMessageType')[msgType]
// PBMessageRequest是公共请求体,携带一些额外的token等信息,后端通过type获得接口名称,messageData获得请求数据
const PBMessageRequest = proto.lookup('framework.PBMessageRequest')
const req = PBMessageRequest.encode({
timeStamp: new Date().getTime(),
type: _msgType,
version: '1.0',
messageData: requestBody,
token: 'xxxxxxx'
}).finish()
// 发起请求,在vue中我们可以使用axios发起ajax,但node端需要换一个,比如"request"
// 我这里推荐使用一个不错的库:"request-promise",它支持promise
const options = {
method: 'POST',
uri: 'http://your_server.com/api',
body: req,
encoding: null,
headers: {
'Content-Type': 'application/octet-stream'
}
}
return rp.post(options).then((res) => {
// 解析二进制返回值
const decodeResponse = proto.lookup('framework.PBMessageResponse').decode(res)
const { resultInfo, resultCode } = decodeResponse
if (resultCode === 0) {
// 进一步解析解析PBMessageResponse中的messageData
const model = proto.lookup(responseType)
let msgData = model.decode(decodeResponse.messageData)
return msgData
} else {
throw new Error(`Fetch ${msgType} failed.`)
}
})
}
module.exports = request</code></pre>
<h2>使用</h2>
<p><code>request.js</code>和<code>proto.js</code>提供底层的服务,为了使用方便,我们还要封装一个<code>api.js</code>,定义项目中所有的api。</p>
<pre><code>const request = require('./request')
const proto = require('./proto')
exports.getStudentList = function getStudentList (params) {
const req = proto.create('school.PBStudentListReq', params)
return request('school.getStudentList', req, 'school.PBStudentListRsp')
}</code></pre>
<p>在项目中使用接口时,只需要<code>require('lib/api')</code>,不直接引用proto.js和request.js。</p>
<pre><code>// test.js
const api = require('../lib/api')
const req = {
limit: 20,
offset: 0
}
api.getStudentList(req).then((res) => {
console.log(res)
}).catch(() => {
// ...
})</code></pre>
<h2>最后</h2>
<p><a href="https://link.segmentfault.com/?enc=Jn%2ByToZ95l6VmQ7EIwc0iw%3D%3D.JBRQ6rXygfw6HFALFFaWOtaCgi%2FM9zq9MBr9B9JYoV7RCnnVzI4jvatxDqYyCngvZbpBFSAir8A1I%2F3X4BkdbPuoDz%2FEM9HYzDG7rrHpxtM%3D" rel="nofollow">demo源码</a></p>
如何在前端中使用protobuf(vue篇)
https://segmentfault.com/a/1190000021052292
2019-11-19T17:00:00+08:00
2019-11-19T17:00:00+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
27
<p><img src="/img/bVbAuPt" alt="protobuf.png" title="protobuf.png"></p>
<h2>前言</h2>
<p>由于目前公司采用了ProtoBuf做前后端数据交互,进公司以来一直用的是公司大神写好的基础库,完全不了解底层是如何解析的,一旦报错只能求人,作为一只还算有钻研精神的猿,应该去了解一下底层的实现,在这里记录一下学习过程。</p>
<h2>Protobuf简单介绍</h2>
<blockquote>
<p>Google Protocol Buffer(简称 Protobuf)是一种轻便高效的结构化数据存储格式,平台无关、语言无关、可扩展,可用于通讯协议和数据存储等领域。</p>
<p>有几个优点:</p>
<ul>
<li>1.平台无关,语言无关,可扩展;</li>
<li>2.提供了友好的动态库,使用简单;</li>
<li>3.解析速度快,比对应的XML快约20-100倍;</li>
<li>4.序列化数据非常简洁、紧凑,与XML相比,其序列化之后的数据量约为1/3到1/10。</li>
</ul>
</blockquote>
<p>个人感受: 前后端数据传输用json还是protobuf其实对开发来说没啥区别,protobuf最后还是要解析成json才能用。个人觉得比较好的几点是:</p>
<ul>
<li><strong>1.前后端都可以直接在项目中使用protobuf,不用再额外去定义model;</strong></li>
<li><strong>2.protobuf可以直接作为前后端数据和接口的文档,大大减少了沟通成本;</strong></li>
</ul>
<p>没有使用protobuf之前,后端语言定义的接口和字段,前端是不能直接使用的,前后端沟通往往需要维护一份接口文档,如果后端字段有改动,需要去修改文档并通知前端,有时候文档更新不及时或容易遗漏,沟通成本比较大。<br>使用protobuf后,protobuf文件由后端统一定义,<strong>protobuf直接可以作为文档</strong>,前端只需将protobuf文件拷贝进前端项目即可。如果后端字段有改动,只需通知前端更新protobuf文件即可,因为后端是直接使用了protobuf文件,因此protobuf文件一般是不会出现遗漏或错误的。长此以往,团队合作效率提升是明显的。</p>
<p>废话了一大堆,下面进入正题。 我这里讲的主要是在vue中的使用,是目前本人所在的公司项目实践,大家可以当做参考。</p>
<h2>思路</h2>
<p>前端中需要使用 <a href="https://link.segmentfault.com/?enc=ctoPd2QCgC%2Fv2rWz02Vcvg%3D%3D.DkoLDnGUsYUK71ShlDtdXjVvHUXm%2BTfsNTKXLj4oEj1HANXy%2F%2F9EwEPo1D5C3cNO" rel="nofollow">protobuf.js</a> 这个库来处理proto文件。</p>
<p><code>protobuf.js</code> 提供了几种方式来处理proto。</p>
<ul>
<li>直接解析,如<code>protobuf.load("awesome.proto", function(err, root) {...})</code>
</li>
<li>转化为JSON或js后使用,如<code>protobuf.load("awesome.json", function(err, root) {...})</code>
</li>
<li>其他</li>
</ul>
<p>众所周知,vue项目build后生成的dist目录中只有html,css,js,images等资源,并不会有<code>.proto</code>文件的存在,因此需要用<code>protobuf.js</code>这个库将<code>*.proto</code>处理成<code>*.js</code>或<code>*.json</code>,然后再利用库提供的方法来解析数据,最后得到数据对象。</p>
<blockquote>PS: 实践发现,转化为js文件会更好用一些,转化后的js文件直接在原型链上定义了一些方法,非常方便。因此后面将会是使用这种方法来解析proto。</blockquote>
<h2>预期目标</h2>
<p>在项目中封装一个<code>request.js</code>模块,希望能像下面这样使用,调用api时只需指定请求和响应的model,然后传递请求参数,不需关心底层是如何解析proto的,api返回一个Promise对象:</p>
<pre><code>// /api/student.js 定义接口的文件
import request from '@/lib/request'
// params是object类型的请求参数
// school.PBStudentListReq 是定义好的请求体model
// school.PBStudentListRsp 是定义好的响应model
// getStudentList 是接口名称
export function getStudentList (params) {
const req = request.create('school.PBStudentListReq', params)
return request('getStudentList', req, 'school.PBStudentListRsp')
}
// 在HelloWorld.vue中使用
import { getStudentList } from '@/api/student'
export default {
name: 'HelloWorld',
created () {
},
methods: {
_getStudentList () {
const req = {
limit = 20,
offset = 0
}
getStudentList(req).then((res) => {
console.log(res)
}).catch((res) => {
console.error(res)
})
}
}
}</code></pre>
<h2>准备工作</h2>
<h3>1.拿到一份定义好的proto文件。</h3>
<p>虽然语法简单,但其实前端不用怎么关心如何写proto文件,一般都是由后端来定义和维护。在这里大家可以直接用一下我定义好的一份<a href="https://link.segmentfault.com/?enc=A7faxiL97k5lEK9DrRVZNQ%3D%3D.LyROJd64VL0bnEjKzRMhRLnY%2BdHsSk59ffVb2%2FspjPVc%2FRLHNJ5J7C7chlyfiuG97oPm2%2BqvmtZBkSjMbbc%2BQziImFVILgXyaLCaa%2BvH0%2BtyG0TOkvA86yqoZtWW%2BkVZ" rel="nofollow">demo</a>。</p>
<pre><code>// User.proto
package framework;
syntax = "proto3";
message PBUser {
uint64 user_id = 0;
string name = 1;
string mobile = 2;
}
// Class.proto
package school;
syntax = "proto3";
message PBClass {
uint64 classId = 0;
string name = 1;
}
// Student.proto
package school;
syntax = "proto3";
import "User.proto";
import "Class.proto";
message PBStudent {
uint64 studentId = 0;
PBUser user = 1;
PBClass class = 2;
PBStudentDegree degree = 3;
}
enum PBStudentDegree {
PRIMARY = 0; // 小学生
MIDDLE = 1; // 中学生
SENIOR = 2; // 高中生
COLLEGE = 3; // 大学生
}
message PBStudentListReq {
uint32 offset = 1;
uint32 limit = 2;
}
message PBStudentListRsp {
repeated PBStudent list = 1;
}
// MessageType.proto
package framework;
syntax = "proto3";
// 公共请求体
message PBMessageRequest {
uint32 type = 1; // 消息类型
bytes messageData = 2; // 请求数据
uint64 timestamp = 3; // 客户端时间戳
string version = 4; // api版本号
string token = 14; // 用户登录后服务器返回的 token,用于登录校验
}
// 消息响应包
message PBMessageResponse {
uint32 type = 3; // 消息类型
bytes messageData = 4; // 返回数据
uint32 resultCode = 6; // 返回的结果码
string resultInfo = 7; // 返回的结果消息提示文本(用于错误提示)
}
// 所有的接口
enum PBMessageType {
// 学生相关
getStudentList = 0; // 获取所有学生的列表, PBStudentListReq => PBStudentListRsp
}</code></pre>
<p>其实不用去学习proto的语法都能一目了然。这里有两种命名空间<code>framework</code>和<code>school</code>,<code>PBStudent</code>引用了<code>PBUser</code>,可以认为<code>PBStudent</code>继承了<code>PBUser</code>。</p>
<p>一般来说,前后端需要统一约束一个请求model和响应model,比如请求中哪些字段是必须的,返回体中又有哪些字段,这里用<code>MessageType.proto</code>的<code>PBMessageRequest</code>来定义请求体所需字段,<code>PBMessageResponse</code>定义为返回体的字段。</p>
<p><code>PBMessageType</code> 是接口的枚举,后端所有的接口都写在这里,用注释表示具体请求参数和返回参数类型。比如这里只定义了一个接口<code>getStudentList</code>。</p>
<p>拿到后端提供的这份<code>*.proto</code>文件后,是不是已经可以基本了解到:有一个<code>getStudentList</code>的接口,请求参数是<code>PBStudentListReq</code>,返回的参数是<code>PBStudentListRsp</code>。</p>
<blockquote>所以说proto文件可以直接作为前后端沟通的文档。</blockquote>
<h2>步骤</h2>
<h3>1.新建一个vue项目</h3>
<p>同时添加安装<code>axios</code>和<code>protobufjs</code>。</p>
<pre><code># vue create vue-protobuf
# npm install axios protobufjs --save-dev</code></pre>
<h3>2.在<code>src</code>目录下新建一个<code>proto</code>目录,用来存放<code>*.proto</code>文件,并将写好的proto文件拷贝进去。</h3>
<p>此时的项目目录和<code>package.json</code>:</p>
<p><img src="/img/bVbAuO3" alt="166a5b3874407032.jpg" title="166a5b3874407032.jpg"></p>
<h3>3.将<code>*.proto</code>文件生成<code>src/proto/proto.js</code>(重点)</h3>
<p><code>protobufjs</code>提供了一个叫<a>pbjs</a>的工具,这是一个神器,根据参数不同可以打包成xx.json或xx.js文件。比如我们想打包成json文件,在根目录运行:</p>
<pre><code>npx pbjs -t json src/proto/*.proto > src/proto/proto.json</code></pre>
<p>可以在<code>src/proto</code>目录下生成一个proto.json文件,查看请点击这里。<br>之前说了:实践证明打包成js模块才是最好用的。我这里直接给出最终的命令</p>
<pre><code>npx pbjs -t json-module -w commonjs -o src/proto/proto.js src/proto/*.proto</code></pre>
<p><code>-w</code>参数可以指定打包js的包装器,这里用的是commonjs,详情请各位自己去看文档。运行命令后在src/proto目录下生成的<a href="https://link.segmentfault.com/?enc=gFqib1W6BNjKeTR%2FF05r0Q%3D%3D.Si0jJaQ1cwhMPU6tQ54fVcb06HBZUzQOPp%2FCAviCmAqJH8Nv5fyqYTzOTGIeMbbmePTZzzi2hPW7okZPV5iEFHMKZr4OHk3ZTOudKWZk5bI%2FKZvsTzMOZDRAanxpbEdR" rel="nofollow">proto.js</a>。在chrome中<code>console.log(proto.js)</code>一下:</p>
<p><img src="/img/remote/1460000021052296" alt="" title=""><br>可以发现,这个模块在原型链上定义了<code>load</code>, <code>lookup</code>等非常有用的api,这正是后面我们将会用到的。<br>为以后方便使用,我们将命令添加到package.json的script中:</p>
<pre><code> "scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"proto": "pbjs -t json-module -w commonjs -o src/proto/proto.js src/proto/*.proto"
},</code></pre>
<p>以后更新proto文件后,只需要<code>npm run proto</code>即可重新生成最新的proto.js。</p>
<h3>4. 封装request.js</h3>
<p>在前面生成了proto.js文件后,就可以开始封装与后端交互的基础模块了。首先要知道,我们这里是用axios来发起http请求的。</p>
<p>整个流程:开始调用接口 -> request.js将数据变成二进制 -> 前端真正发起请求 -> 后端返回二进制的数据 -> request.js处理二进制数据 -> 获得数据对象。</p>
<p>可以说request.js相当于一个加密解密的中转站。在<code>src/lib</code>目录下添加一个<code>request.js</code>文件,开始开发:</p>
<p>既然我们的接口都是二进制的数据,所以需要设置axios的请求头,使用arraybuffer,如下:</p>
<pre><code>import axios from 'axios'
const httpService = axios.create({
timeout: 45000,
method: 'post',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/octet-stream'
},
responseType: 'arraybuffer'
})</code></pre>
<p><code>MessageType.proto</code>里面定义了与后端约定的接口枚举、请求体、响应体。发起请求前需要将所有的请求转换为二进制,下面是request.js的主函数</p>
<pre><code>import protoRoot from '@/proto/proto'
import protobuf from 'protobufjs'
// 请求体message
const PBMessageRequest = protoRoot.lookup('framework.PBMessageRequest')
// 响应体的message
const PBMessageResponse = protoRoot.lookup('framework.PBMessageResponse')
const apiVersion = '1.0.0'
const token = 'my_token'
function getMessageTypeValue(msgType) {
const PBMessageType = protoRoot.lookup('framework.PBMessageType')
const ret = PBMessageType.values[msgType]
return ret
}
/**
*
* @param {*} msgType 接口名称
* @param {*} requestBody 请求体参数
* @param {*} responseType 返回值
*/
function request(msgType, requestBody, responseType) {
// 得到api的枚举值
const _msgType = getMessageTypeValue(msgType)
// 请求需要的数据
const reqData = {
timeStamp: new Date().getTime(),
type: _msgType,
version: apiVersion,
messageData: requestBody,
token: token
}
}
// 将对象序列化成请求体实例
const req = PBMessageRequest.create(reqData)
// 调用axios发起请求
// 这里用到axios的配置项:transformRequest和transformResponse
// transformRequest 发起请求时,调用transformRequest方法,目的是将req转换成二进制
// transformResponse 对返回的数据进行处理,目的是将二进制转换成真正的json数据
return httpService.post('/api', req, {
transformRequest,
transformResponse: transformResponseFactory(responseType)
}).then(({data, status}) => {
// 对请求做处理
if (status !== 200) {
const err = new Error('服务器异常')
throw err
}
console.log(data)
},(err) => {
throw err
})
}
// 将请求数据encode成二进制,encode是proto.js提供的方法
function transformRequest(data) {
return PBMessageRequest.encode(data).finish()
}
function isArrayBuffer (obj) {
return Object.prototype.toString.call(obj) === '[object ArrayBuffer]'
}
function transformResponseFactory(responseType) {
return function transformResponse(rawResponse) {
// 判断response是否是arrayBuffer
if (rawResponse == null || !isArrayBuffer(rawResponse)) {
return rawResponse
}
try {
const buf = protobuf.util.newBuffer(rawResponse)
// decode响应体
const decodedResponse = PBMessageResponse.decode(buf)
if (decodedResponse.messageData && responseType) {
const model = protoRoot.lookup(responseType)
decodedResponse.messageData = model.decode(decodedResponse.messageData)
}
return decodedResponse
} catch (err) {
return err
}
}
}
// 在request下添加一个方法,方便用于处理请求参数
request.create = function (protoName, obj) {
const pbConstruct = protoRoot.lookup(protoName)
return pbConstruct.encode(obj).finish()
}
// 将模块暴露出去
export default request</code></pre>
<p>最后写好的具体代码请看:<a href="https://link.segmentfault.com/?enc=%2Bk%2FfTyKeW1TST2Kf3YfWsA%3D%3D.lH48VdAyQ1cqzTJSH6WPYGoP%2FOEbdyBHlfARHhU8moh%2BEA7RNOO0AKXpLzdkqrdqj2cdTT%2Fv%2Bdu3D%2B4jlylkUOLLNnTDsr0CfGRK01dpdQ4m0wKI1xYa0o%2FSIKUT2XO8" rel="nofollow">request.js</a>。<br>其中用到了<code>lookup()</code>,<code>encode()</code>, <code>finish()</code>, <code>decode()</code>等几个proto.js提供的方法。</p>
<h3>5. 调用request.js</h3>
<p>在.vue文件直接调用api前,我们一般不直接使用request.js来直接发起请求,而是将所有的接口再封装一层,因为直接使用request.js时要指定请求体,响应体等固定的值,多次使用会造成代码冗余。</p>
<p>我们习惯上在项目中将所有后端的接口放在<code>src/api</code>的目录下,如针对student的接口就放在<code>src/api/student.js</code>文件中,方便管理。<br>将<code>getStudentList</code>的接口写在<code>src/api/student.js</code>中</p>
<pre><code>import request from '@/lib/request'
// params是object类型的请求参数
// school.PBStudentListReq 是定义好的请求体model
// school.PBStudentListRsp 是定义好的响应model
// getStudentList 是接口名称
export function getStudentList (params) {
const req = request.create('PBStudentListReq', params)
return request('getStudentList', req, 'school.PBStudentListRsp')
}
// 后面如果再添加接口直接以此类推
export function getStudentById (id) {
// const req = ...
// return request(...)
}</code></pre>
<h3>6. 在.vue中使用接口</h3>
<p>需要哪个接口,就import哪个接口,返回的是Promise对象,非常方便。</p>
<pre><code><template>
<div class="hello">
<button @click="_getStudentList">获取学生列表</button>
</div>
</template>
<script>
import { getStudentList } from '@/api/student'
export default {
name: 'HelloWorld',
methods: {
_getStudentList () {
const req = {
limit: 20,
offset: 0
}
getStudentList(req).then((res) => {
console.log(res)
}).catch((res) => {
console.error(res)
})
}
},
created () {
}
}
</script>
<style lang="scss">
</style></code></pre>
<h2>总结</h2>
<p>整个demo的代码: <a href="https://link.segmentfault.com/?enc=8APwaXOzhGXW1YAO4nezGg%3D%3D.TlzTwuirE4Q6ddhEDBY3HZe%2BJm3Es5fPT%2F0IUR%2Bcj90a5Fdsp4dAznUknwiuGfIWUNqS5ICb%2BYv5p7%2Fq08VJ8x%2BuF1YS3Zx%2BAenoa0bOx6E%3D" rel="nofollow">demo</a>。</p>
<p>前端使用的整个流程:</p>
<ul>
<li><strong>1. 将后端提供的所有的proto文件拷进<code>src/proto</code>文件夹</strong></li>
<li><strong>2. 运行<code>npm run proto</code> 生成proto.js</strong></li>
<li><strong>3. 根据接口枚举在<code>src/api</code>下写接口</strong></li>
<li><strong>4. <code>.vue</code>文件中使用接口。</strong></li>
</ul>
<p>(其中1和2可以合并在一起写一个自动化的脚本,每次更新只需运行一下这个脚本即可)。</p>
<p>写的比较啰嗦,文笔也不好,大家见谅。</p>
<p>这个流程就是我感觉比较好的一个proto在前端的实践,可能并不是最好,如果在你们公司有其他更好的实践,欢迎大家一起交流分享。</p>
<h2>后续</h2>
<p>在vue中使用是需要打包成一个js模块来使用比较好(这是因为vue在生产环境中打包成只有html,css,js等文件)。但在某些场景,比如在Node环境中,一个Express的项目,生产环境中是允许出现<code>.proto</code>文件的,这时候可以采取<code>protobuf.js</code>提供的其他方法来动态解析proto,不再需要npm run proto这种操作了。</p>
<p>后面有时间我会再写一篇在node端动态解析proto的记录。</p>
axios如何利用promise无痛刷新token(二)
https://segmentfault.com/a/1190000020986592
2019-11-13T10:56:01+08:00
2019-11-13T10:56:01+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
85
<p><img src="/img/bVbAdH6" alt="16e6265fc4958f20.jpg" title="16e6265fc4958f20.jpg"></p><h2>前言</h2><p>前段时间写了篇文章<a href="https://segmentfault.com/a/1190000020210980">《axios如何利用promise无痛刷新token》</a>,陆陆续续收到一些反馈。发现不少同学会想要从<code>在请求前拦截</code>的思路入手,甚至收到了几个邮件来询问博主遇到的问题,所以索性再写一篇文章来说说另一个思路的实现和注意的地方。过程会稍微啰嗦,不想看实现过程的同学可以直接拉到最后面看最终代码。</p><blockquote>PS:在本文就略过一些前提条件了,请新同学阅读本文前先看一下前一篇文章<a href="https://segmentfault.com/a/1190000020210980">《axios如何利用promise无痛刷新token》</a>。</blockquote><h2>前提条件</h2><p>前端登录后,后端返回<code>token</code>和token有效时间段<code>tokenExprieIn</code>,当token过期时间到了,前端需要主动用旧token去获取一个新的token,做到用户无感知地去刷新token。</p><blockquote>PS: <code>tokenExprieIn</code>是一个单位为秒的时间段,不建议使用绝对时间,绝对时间可能会由于本地和服务器时区不一样导致出现问题。</blockquote><h2>实现思路</h2><h3>方法一</h3><p>在请求发起前拦截每个请求,判断token的有效时间是否已经过期,若已过期,则将请求挂起,先刷新token后再继续请求。</p><h3>方法二</h3><p>不在请求前拦截,而是拦截返回后的数据。先发起请求,接口返回过期后,先刷新token,再进行一次重试。</p><p>前文已经实现了方法二,本文会从头实现一下<em>方法一</em>。</p><h2>实现</h2><h3>基本骨架</h3><p>在请求前进行拦截,我们主要会使用<code>axios.interceptors.request.use()</code>这个方法。照例先封装个<code>request.js</code>的基本骨架:</p><pre><code>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</code></pre><p>与前文略微不同的是,由于<em>方法二</em>不需要用到过期时间,所以前文localStorage中只存了token一个字符串,而方法一这里需要用到过期时间了,所以得存多一个数据,因此localStorage中存的是<code>Object</code>类型的数据,从localStorage中取值出来需要<code>JSON.parse</code>一下,为了防止发生错误所以尽量使用<code>try...catch</code>。</p><h3>axios.interceptors.request.use()实现</h3><p>首先不需要想得太复杂,先不考虑多个请求同时进来的情况,咱从最常见的场景入手:从localStorage拿到上一次存储的过期时间,判断是否已经到了过期时间,是就立即刷新token然后再发起请求。</p><pre><code>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)
})</code></pre><p>这里有两个需要注意的地方:</p><ol><li>之前说到登录或刷新token的接口返回的是一个单位为秒的时间段<code>tokenExpireIn</code>,而我们存到localStorage中的是已经是一个基于<code>当前时间</code>和<code>有效时间段</code>算出的最终时间<code>tokenExpireTime</code>,是一个绝对时间,比如当前时间是12点,有效时间是3600秒(1个小时),则存到localStorage的过期时间是13点的时间戳,这样可以少存一个当前时间的字段到localStorage中,使用时只需要判断该绝对时间即可。</li><li><code>instance.interceptors.request.use</code>中返回一个Promise,就可以使得该请求是先执行<code>refreshToken</code>后再<code>return config</code>的,才能保证先刷新token后再真正发起请求。</li></ol><p>其实博主直接运行上面代码后发现了一个严重错误,进入了一个死循环。这是因为博主没有注意到一个问题:<code>axios.interceptors.request.use()</code>会拦截所有使用该实例发起的请求,即执行<code>refreshToken()</code>时又一次进入了<code>axios.interceptors.request.use()</code>,导致一直在<code>return refreshToken()</code>。</p><p>因此需要将刷新token和登录这两种情况排除出去,登录和刷新token都不需要判断是否过期的拦截,我们可以通过config.url来判断是哪个接口:</p><pre><code>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)
})</code></pre><h2>问题和优化</h2><p>接下来就是要考虑复杂一点的问题了</p><h3>防止多次刷新token</h3><p>当几乎同时进来两个请求,为了避免多次执行refreshToken,需要引入一个<code>isRefreshing</code>的进行标记:</p><pre><code>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)
})</code></pre><h3>多个请求时存到队列中等刷新token后再发起</h3><p>我们已经知道了当前已经过期或者正在刷新token,此时再有请求发起,就应该让后面的这些请求等一等,等到refreshToken结束后再真正发起,所以需要用到一个Promise来让它一直等。而后面的所有请求,我们将它们存放到一个<code>requests</code>的队列中,等刷新token后再依次<code>resolve</code>。</p><pre><code>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)
})</code></pre><p>这里做了一点改动,注意到<code>refreshToken()</code>这一句前面去掉了<code>return</code>,而是改为了在后面<code>return retryOriginalRequest</code>,即当发现有请求是过期的就存进<code>requests</code>数组,等refreshToken结束后再执行<code>requests</code>队列,这是为了不影响原来的请求执行次序。<br>我们假设同时有<code>请求1</code>,<code>请求2</code>,<code>请求3</code>依次同时进来,我们希望是<code>请求1</code>发现过期,refreshToken后再依次执行<code>请求1</code>,<code>请求2</code>,<code>请求3</code>。<br>按之前<code>return refreshToken()</code>的写法,会大概写成这样</p><pre><code>
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</code></pre><p>队列里面只有<code>请求2</code>和<code>请求3</code>,代码看起来应该是return了请求1后,再在finally执行队列的,但实际的执行顺序会变成<code>请求2</code>,<code>请求3</code>,<code>请求1</code>,即请求1变成了最后一个执行的,会改变执行顺序。</p><p>所以博主换了个思路,无论是哪个请求进入了过期流程,我们都将请求放到队列中,都return一个未resolve的Promise,等刷新token结束后再一一清算,这样就可以保证<code>请求1</code>,<code>请求2</code>,<code>请求3</code>这样按原来顺序执行了。</p><p>这里多说一句,可能很多刚接触前端的同学无法理解<code>requests.forEach(cb => cb(token))</code>是如何执行的。</p><pre><code>// 我们先看一下,定义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()
})</code></pre><h2>最后完整代码</h2><pre><code>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中
}
// 是否正在刷新的标记
let isRefreshing = false
// 重试队列,每一项将是一个待执行的函数形式
let requests = []
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</code></pre><p>建议一步步调试的同学,可以先去掉<code>window.location.href = '/'</code>这个跳转,保留log方便调试。</p><p>感谢看到最后,感谢点赞^_^。</p>
axios如何利用promise无痛刷新token
https://segmentfault.com/a/1190000020210980
2019-08-28T12:02:41+08:00
2019-08-28T12:02:41+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
230
<h2>需求</h2>
<p>最近遇到个需求:前端登录后,后端返回<code>token</code>和<code>token有效时间</code>,当token过期时要求用旧token去获取新的token,前端需要做到无痛刷新<code>token</code>,即请求刷新token时要做到用户无感知。</p>
<h2>需求解析</h2>
<p>当用户发起一个请求时,判断token是否已过期,若已过期则先调<code>refreshToken</code>接口,拿到新的token后再继续执行之前的请求。</p>
<p>这个问题的难点在于:当同时发起多个请求,而刷新token的接口还没返回,此时其他请求该如何处理?接下来会循序渐进地分享一下整个过程。</p>
<h2>实现思路</h2>
<p>由于后端返回了token的有效时间,可以有两种方法:</p>
<h3>方法一:</h3>
<p>在请求发起前拦截每个请求,判断token的有效时间是否已经过期,若已过期,则将请求挂起,先刷新token后再继续请求。</p>
<h3>方法二:</h3>
<p>不在请求前拦截,而是拦截返回后的数据。先发起请求,接口返回过期后,先刷新token,再进行一次重试。</p>
<h3>两种方法对比</h3>
<p>方法一</p>
<ul>
<li>优点: 在请求前拦截,能节省请求,省流量。</li>
<li>缺点: 需要后端额外提供一个token过期时间的字段;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。</li>
</ul>
<blockquote>PS:token有效时间建议是时间段,类似缓存的MaxAge,而不要是绝对时间。当服务器和本地时间不一致时,绝对时间会有问题。</blockquote>
<p>方法二</p>
<ul>
<li>优点:不需额外的token过期字段,不需判断时间。</li>
<li>缺点: 会消耗多一次请求,耗流量。</li>
</ul>
<p>综上,方法一和二优缺点是互补的,方法一有校验失败的风险(本地时间被篡改时,当然一般没有用户闲的蛋疼去改本地时间的啦),方法二更简单粗暴,等知道服务器已经过期了再重试一次,只是会耗多一个请求。</p>
<p>在这里博主选择了 <em>方法二</em>。</p>
<h2>实现</h2>
<p>这里会使用<a href="https://link.segmentfault.com/?enc=Vfd70SPNpG5dlPRqTCb1xA%3D%3D.NfoFr%2FhGbkfN%2FmogJ6MhDjI5SysjUFRn82UkPksoo14%3D" rel="nofollow">axios</a>来实现,方法一是请求前拦截,所以会使用<code>axios.interceptors.request.use()</code>这个方法;</p>
<p>而方法二是请求后拦截,所以会使用<code>axios.interceptors.response.use()</code>方法。</p>
<h3>封装axios基本骨架</h3>
<p>首先说明一下,项目中的token是存在<code>localStorage</code>中的。<code>request.js</code>基本骨架:</p>
<pre><code>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</code></pre>
<p>这个是项目中一般的axios实例的封装,创建实例时,将本地已有的token放进header,然后export出去供调用。接下来就是如何拦截返回的数据啦。</p>
<h3>instance.interceptors.response.use拦截实现</h3>
<p>后端接口一般会有一个约定好的数据结构,如:</p>
<pre><code>{code: 1234, message: 'token过期', data: {}}</code></pre>
<p>如我这里,后端约定当<code>code === 1234</code>时表示token过期了,此时就要求刷新token。</p>
<pre><code>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)
}</code></pre>
<p>这里需要额外注意的是,<code>response.config</code>就是原请求的配置,但这个是已经处理过了的,<code>config.url</code>已经带上了<code>baseUrl</code>,因此重试时需要去掉,同时token也是旧的,需要刷新下。</p>
<p>以上就基本做到了无痛刷新token,当token正常时,正常返回,当token已过期,则axios内部进行一次刷新token和重试。对调用者来说,axios内部的刷新token是一个黑盒,是无感知的,因此需求已经做到了。</p>
<h2>问题和优化</h2>
<p>上面的代码还是存在一些问题的,没有考虑到多次请求的问题,因此需要进一步优化。</p>
<h3>如何防止多次刷新token</h3>
<p>如果refreshToken接口还没返回,此时再有一个过期的请求进来,上面的代码就会再一次执行refreshToken,这就会导致多次执行刷新token的接口,因此需要防止这个问题。我们可以在<code>request.js</code>中用一个<code>flag</code>来标记当前是否正在刷新token的状态,如果正在刷新则不再调用刷新token的接口。</p>
<pre><code>// 是否正在刷新的标记
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)
})</code></pre>
<p>这样子就可以避免在刷新token时再进入方法了。但是这种做法是相当于把其他失败的接口给舍弃了,假如同时发起两个请求,且几乎同时返回,第一个请求肯定是进入了refreshToken后再重试,而第二个请求则被丢弃了,仍是返回失败,所以接下来还得解决其他接口的重试问题。</p>
<h3>同时发起两个或以上的请求时,其他接口如何重试</h3>
<p>两个接口几乎同时发起和返回,第一个接口会进入刷新token后重试的流程,而第二个接口需要先存起来,然后等刷新token后再重试。同样,如果同时发起三个请求,此时需要缓存后两个接口,等刷新token后再重试。由于接口都是异步的,处理起来会有点麻烦。</p>
<p>当第二个过期的请求进来,token正在刷新,我们先将这个请求存到一个数组队列中,想办法让这个请求处于等待中,一直等到刷新token后再逐个重试清空请求队列。<br>那么如何做到让这个请求处于等待中呢?为了解决这个问题,我们得借助<code>Promise</code>。将请求存进队列中后,同时返回一个<code>Promise</code>,让这个<code>Promise</code>一直处于<code>Pending</code>状态(即不调用resolve),此时这个请求就会一直等啊等,只要我们不执行resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用resolve,逐个重试。最终代码:</p>
<pre><code>// 是否正在刷新的标记
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)
})</code></pre>
<p>这里可能比较难理解的是<code>requests</code>这个队列中保存的是一个函数,这是为了让resolve不执行,先存起来,等刷新token后更方便调用这个函数使得resolve执行。至此,问题应该都解决了。</p>
<h2>最后完整代码</h2>
<pre><code>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</code></pre>
<p>希望对大家有帮助。感谢看到最后,感谢点赞^_^。</p>
<h2>后续更新</h2>
<p>针对方法一的实现,请大家阅读: <a href="https://segmentfault.com/a/1190000020986592">axios如何利用promise无痛刷新token(二)</a></p>
Nuxt.js实战和配置
https://segmentfault.com/a/1190000019972611
2019-08-05T11:27:38+08:00
2019-08-05T11:27:38+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
32
<p>前段时间刚好公司有项目使用了Nuxt.js来搭建,而刚好在公司内部做了个分享,稍微再整理一下发出来。本文比较适合初用Nuxt.js的同学,主要讲下搭建过程中做的一些配置。建议初次使用Nuxt.js的同学先过一遍<a href="https://link.segmentfault.com/?enc=GKUFfO%2B6XGREJC%2FJkU3FPA%3D%3D.tPrN285n0uIrqylA2YUxS9XNcmsI3j%2FgmjGyJK4ds10%3D" rel="nofollow">官方文档</a>,再回头看下我这篇文章。</p>
<h2>一、为什么要用Nuxt.js</h2>
<p>原因其实不用多说,就是利用Nuxt.js的服务端渲染能力来解决Vue项目的SEO问题。</p>
<h2>二、Nuxt.js和纯Vue项目的简单对比</h2>
<h3>1. build后目标产物不同</h3>
<p>vue: dist</p>
<p>nuxt: .nuxt</p>
<h3>2. 网页渲染流程</h3>
<p>vue: 客户端渲染,先下载js后,通过ajax来渲染页面;</p>
<p>nuxt: 服务端渲染,可以做到服务端拼接好html后直接返回,首屏可以做到无需发起ajax请求;</p>
<h3>3. 部署流程</h3>
<p>vue: 只需部署dist目录到服务器,没有服务端,需要用nginx等做Web服务器;</p>
<p>nuxt: 需要部署几乎所有文件到服务器(除node_modules,.git),自带服务端,需要pm2管理(部署时需要reload pm2),若要求用域名,则需要nginx做代理。</p>
<h3>4. 项目入口</h3>
<p>vue: <code>/src/main.js</code>,在main.js可以做一些全局注册的初始化工作;<br>nuxt: 没有main.js入口文件,项目初始化的操作需要通过<code>nuxt.config.js</code>进行配置指定。</p>
<h2>三、从零搭建一个Nuxt.js项目并配置</h2>
<h3>新建一个项目</h3>
<p>直接使用脚手架进行安装:</p>
<pre><code>npx create-nuxt-app <项目名></code></pre>
<p><img src="/img/bVbvXWR?w=1384&h=439" alt="图片描述" title="图片描述"><br>大概选上面这些选项。</p>
<p>值得一说的是,关于<code>Choose custom server framework</code>(选择服务端框架),可以根据你的业务情况选择一个服务端框架,常见的就是Express、Koa,默认是None,即Nuxt默认服务器,我这里选了<code>Express</code>。</p>
<ul>
<li>选择默认的Nuxt服务器,不会生成<code>server</code>文件夹,所有服务端渲染的操作都是Nuxt帮你完成,无需关心服务端的细节,开发体验更接近Vue项目,缺点是无法做一些服务端定制化的操作。</li>
<li>选择其他的服务端框架,比如<code>Express</code>,会生成<code>server</code>文件夹,帮你搭建一个基本的Node服务端环境,可以在里面做一些node端的操作。比如我公司业务需要(解析protobuf)使用了<code>Express</code>,对真正的服务端api做一层转发,在node端解析protobuf后,返回json数据给客户端。</li>
</ul>
<p>还有<code>Choose Nuxt.js modules</code>(选择nuxt.js的模块),可以选<code>axios</code>和<code>PWA</code>,如果选了axios,则会帮你在nuxt实例下注册<code>$axios</code>,让你可以在.vue文件中直接<code>this.$axios</code>发起请求。</p>
<h3>开启eslint检查</h3>
<p>在<code>nuxt.config.js</code>的build属性下添加:</p>
<pre><code> 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)/
})
}
}
}</code></pre>
<p>这样开发时保存文件就可以检查语法了。nuxt默认使用的规则是<a href="https://link.segmentfault.com/?enc=CJS4LpJTW7MqltDDzGOjWA%3D%3D.PFKE%2BbK%2FR8pCjY5g2%2FaBmmXOxUC66Iu4so7o%2Fp0Mv9RENPV84VDanvv%2BqGovIzdi" rel="nofollow">@nuxtjs</a>(底层来自<a href="https://link.segmentfault.com/?enc=vmPZ2vrig%2FATNhgGaf23VA%3D%3D.Z%2FxRivZLF2vR9j9cnt%2B4VHwxy6aRa%2Bx2K1J99Aj7Jg6CHQ9yplSA0K6C9re23VPD6xXG59VQOLVXNMLnmxUSrQ%3D%3D" rel="nofollow">eslint-config-standard</a>),规则配置在<code>/.eslintrc.js</code>:</p>
<pre><code>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'
}
}
</code></pre>
<p>如果不习惯用<code>standard</code>规则的团队可以将<code>@nuxtjs</code>改成其他的。</p>
<h3>使用dotenv和@nuxtjs/dotenv统一管理环境变量</h3>
<p>在node端,我们喜欢使用<code>dotenv</code>来管理项目中的环境变量,把所有环境变量都放在根目录下的<code>.env</code>中。</p>
<ul><li>安装:</li></ul>
<pre><code>npm i dotenv</code></pre>
<ul><li>使用:</li></ul>
<ol><li>在根目录下新建一个<code>.env</code>文件,并写上需要管理的环境变量,比如服务端地址<code>APIHOST</code>:</li></ol>
<pre><code>APIHOST=http://your_server.com/api</code></pre>
<ol><li>在<code>/server/index.js</code>中使用(该文件是选Express服务端框架自动生成的):</li></ol>
<pre><code>require('dotenv').config()
// 通过process.env即可使用
console.log(process.env.APIHOST) // http://your_server.com/api</code></pre>
<p>此时我们只是让服务端可以使用<code>.env</code>的文件而已,Nuxt客户端并不能使用<code>.env</code>,按<a href="https://link.segmentfault.com/?enc=2eVGnhOQtekAGkiRTsU%2BUw%3D%3D.QUdDg5Fo4FC1HBCWI%2BOY9uVPGSVZIwuGKN7K1ibcQvAj%2FeferSI75yA3H9ZMk%2BZC" rel="nofollow">Nuxt.js文档</a>所说,可以将客户端的环境变量放置在<code>nuxt.config.js</code>中:</p>
<pre><code>module.exports = {
env: {
baseUrl: process.env.BASE_URL || 'http://localhost:3000'
}
}</code></pre>
<p>但如果node端和客户端需要使用同一个环境变量时(后面讲到API鉴权时会使用同一个SECRET变量),就需要同时在<code>nuxt.config.js</code>和<code>.env</code>维护这个字段,比较麻烦,我们更希望环境变量只需要在一个地方维护,所以为了解决这个问题,我找到了<code>@nuxtjs/dotenv</code>这个依赖,它使得nuxt的客户端也可以直接使用<code>.env</code>,达到了我们的预期。</p>
<ul><li>安装:</li></ul>
<pre><code>npm i @nuxtjs/dotenv</code></pre>
<p>客户端也是通过<code>process.env.XXX</code>来使用,不再举例啦。</p>
<p>这样,我们通过<code>dotenv</code>和<code>@nuxtjs/dotenv</code>这两个包,就可以统一管理开发环境中的变量啦。</p>
<p>另外,<code>@nuxtjs/dotenv</code>允许打包时指定其他的env文件。比如,开发时我们使用的是<code>.env</code>,但我们打包的线上版本想用其他的环境变量,此时可以指定build时用另一份文件如<code>/.env.prod</code>,只需在<code>nuxt.config.js</code>指定:</p>
<pre><code>module.exports = {
modules: [
['@nuxtjs/dotenv', { filename: '.env.prod' }] // 指定打包时使用的dotenv
],
}</code></pre>
<h3>@nuxtjs/toast模块</h3>
<p>toast可以说是很常用的功能,一般的UI框架都会有这个功能。但如果你的站点没有使用UI框架,而alert又太丑,不妨引入该模块:</p>
<pre><code>npm install @nuxtjs/toast</code></pre>
<p>然后在<code>nuxt.config.js</code>中引入</p>
<pre><code>module.exports = {
modules: [
'@nuxtjs/toast',
['@nuxtjs/dotenv', { filename: '.env.prod' }] // 指定打包时使用的dotenv
],
toast: {// toast模块的配置
position: 'top-center',
duration: 2000
}
}</code></pre>
<p>这样,nuxt就会在全局注册<code>$toast</code>方法供你使用,非常方便:</p>
<pre><code>this.$toast.error('服务器开小差啦~~')
this.$toast.error('请求成功~~')</code></pre>
<h3>API鉴权</h3>
<p>对于某些敏感的服务,我们可能需要对API进行鉴权,防止被人轻易盗用我们node端的API,因此我们需要做一个API的鉴权机制。常见的方案有jwt,可以参考一下阮老师的介绍:<a href="https://link.segmentfault.com/?enc=cPQaq2RrZjoL5UYZ4MOe9w%3D%3D.s9%2Fr4N%2FxqIKLLTPwW6sR34QytqiXyiCFmjuToEHPWcgn9RPPnWwPs5KNvBw3fD6E9olJ%2BWoCBu3q9pK1SFLnF3oBFytwYPsbUu3tyjyPO7A%3D" rel="nofollow">《JSON Web Token 入门教程》</a>。如果场景比较简单,可以自行设计一下,这里提供一个思路:</p>
<ol>
<li>客户端和node端在环境变量中声明一个秘钥:SECRET=xxxx,注意这个是保密的;</li>
<li>客户端发起请求时,将当前时间戳(timestamp)和<code>SECRET</code>通过某种算法,生成一个<code>signature</code>,请求时带上<code>timestamp</code>和<code>signature</code>;</li>
<li>node接收到请求,获得<code>timestamp</code>和<code>signature</code>,将<code>timestamp</code>和秘钥用同样的算法再生成一次签名<code>_signature</code>
</li>
<li>对比客户端请求的<code>signature</code>和node用同样的算法生成的<code>_signature</code>,如果一致就表示通过,否则鉴权失败。</li>
</ol>
<p>具体的步骤:</p>
<p>客户端对axios进行一层封装:</p>
<pre><code>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</code></pre>
<p>接着,在server端写一个鉴权的中间件,<code>/server/middleware/verify.js</code>:</p>
<pre><code>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 }</code></pre>
<p>最后,在需要鉴权的路由中引用这个中间件, <code>/server/index.js</code>:</p>
<pre><code>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'})
})</code></pre>
<h3>静态文件的处理</h3>
<p>根目录下有个<code>/static</code>文件夹,我们希望这里面的文件可以直接通过url访问,需要在<code>/server/index.js</code>中加入一句:</p>
<pre><code>const express = require('express')
const app = express()
app.use('/static', express.static('static'))</code></pre>
<h2>四、Nuxt开发相关</h2>
<h3>生命周期</h3>
<p>Nuxt扩展了Vue的生命周期,大概如下:</p>
<pre><code>export default {
middleware () {}, //服务端
validate () {}, // 服务端
asyncData () {}, //服务端
fetch () {}, // store数据加载
beforeCreate () { // 服务端和客户端都会执行},
created () { // 服务端和客户端都会执行 },
beforeMount () {},
mounted () {} // 客户端
}</code></pre>
<h3>asyncData</h3>
<p>该方法是Nuxt最大的一个卖点,服务端渲染的能力就在这里,首次渲染时务必使用该方法。<br>asyncData会传进一个context参数,通过该参数可以获得一些信息,如:</p>
<pre><code>export default {
asyncData (ctx) {
ctx.app // 根实例
ctx.route // 路由实例
ctx.params //路由参数
ctx.query // 路由问号后面的参数
ctx.error // 错误处理方法
}
}</code></pre>
<h3>渲染出错和ajax请求出错的处理</h3>
<ul><li>asyncData渲染出错</li></ul>
<p>使用<code>asyncData</code>钩子时可能会由于服务器错误或api错误导致无法渲染,此时页面还未渲染出来,需要针对这种情况做一些处理,当遇到asyncData错误时,跳转到错误页面,nuxt提供了<code>context.error</code>方法用于错误处理,在asyncData中调用该方法即可跳转到错误页面。</p>
<pre><code>export default {
async asyncData (ctx) {
// 尽量使用try catch的写法,将所有异常都捕捉到
try {
throw new Error()
} catch {
ctx.error({statusCode: 500, message: '服务器开小差了~' })
}
}
}</code></pre>
<p>这样,当出现异常时会跳转到<a href="https://link.segmentfault.com/?enc=NwjHdrOvVhcjJgC%2F2kXy%2BQ%3D%3D.UACnDltNp82%2BUOp1AtgdYZK1CcL1SX6U1XutL5QiL969XKS7AFmPtA7XgM2jppXGIrur4HVUAcSKH4rm347UWiUmTXDliKHvX0xZGWap4D8%3D" rel="nofollow">默认的错误页</a>,错误页面可以通过<code>/layout/error.vue</code>自定义。</p>
<p>这里会遇到一个问题,<code>context.error</code>的参数必须是类似<code>{ statusCode: 500, message: '服务器开小差了~' }</code>,<code>statusCode</code>必须是http状态码,<br>而我们服务端返回的错误往往有一些其他的自定义代码,如<code>{resultCode: 10005, resultInfo: '服务器内部错误' }</code>,此时需要对返回的api错误进行转换一下。</p>
<p>为了方便,我引入了<code>/plugins/ctx-inject.js</code>为context注册一个全局的错误处理方法: <code>context.$errorHandler(err)</code>。注入方法可以参考:<a href="https://link.segmentfault.com/?enc=UzHsfuRC9m9tZpqctHyh%2BQ%3D%3D.6jnu0N5C2dgnFKd%2B9FUGpXNiwthhUavhVvAYEAKrrDORVKaIjGsRfBLdUapEiiufwCVNpX9P6DfsVsauOV7egkI%2B9C2k8nXomG%2FliEho3KE%3D" rel="nofollow">注入 $root 和 context</a>,<code>ctx-inject.js</code>:</p>
<pre><code>// 为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: '服务器开小差了~' })
}
}
}</code></pre>
<p>然后在<code>nuxt.config.js</code>使用该插件:</p>
<pre><code>export default {
plugins: [
'~/plugins/ctx-inject.js'
]
}</code></pre>
<p>注入完毕,我们就可以在<code>asyncData</code>介个样子使用了:</p>
<pre><code>export default {
async asyncData (ctx) {
// 尽量使用try catch的写法,将所有异常都捕捉到
try {
throw new Error()
} catch(err) {
ctx.$errorHandler(err)
}
}
}</code></pre>
<ul><li>ajax请求出错</li></ul>
<p>对于ajax的异常,此时页面已经渲染,出现错误时不必跳转到错误页,可以通过<code>this.$toast.error(res.message)</code> toast出来即可。</p>
<h3>loading方法</h3>
<p>nuxt内置了页面顶部<a href="https://link.segmentfault.com/?enc=Se6SpW0fx4e2Ade8r6It0g%3D%3D.pET6Dd%2Bu9%2B%2FyAcD%2BtyI2gtF7oVzUCYQGSR4qkr1kMRTcWAISa%2BBvXQ02ZptUq7Yyg2zHFznoeLIqoVGqxM29o2f3QD%2Bu1rO2VE6RnWJHG4oAj7B7tLvllPDqeIO5UF2c" rel="nofollow">loading进度条的样式</a><br>推荐使用,提供页面跳转体验。<br>打开: <code>this.$nuxt.$loading.start()</code><br>完成: <code>this.$nuxt.$loading.finish()</code></p>
<h3>打包部署</h3>
<p>一般来说,部署前可以先在本地打包,本地跑一下确认无误后再上传到服务器部署。命令:</p>
<pre><code>// 打包
npm run build
// 本地跑
npm start</code></pre>
<p>除node_modules,.git,.env,将其他的文件都上传到服务器,然后通过<code>pm2</code>进行管理,可以在项目根目录建一个<code>pm2.json</code>方便维护:</p>
<pre><code>{
"name": "nuxt-test",
"script": "./server/index.js",
"instances": 2,
"cwd": "."
}</code></pre>
<p>然后配置生产环境的环境变量,一般是直接用<code>.env.prod</code>的配置:<code>cp ./.env.prod ./.env</code>。<br>首次部署或有新的依赖包,需要在服务器上<code>npm install</code>一次,然后就可以用<code>pm2</code>启动进程啦:</p>
<pre><code>// 项目根目录下运行
pm2 start ./pm2.json</code></pre>
<p>需要的话,可以设置开机自动启动pm2: <code>pm2 save && pm2 startup</code>。<br>需要注意的是,每次部署都得重启一下进程:<code>pm2 reload nuxt-test</code>。</p>
<h2>五、最后</h2>
<p>Nuxt.js引入了Node,同时<code>nuxt.config.js</code>替代了<code>main.js</code>的一些作用,目录结构和vue项目都稍有不同,增加了很多的约定,对于初次接触的同学可能会觉得非常陌生,更多的内容还是得看一遍官方的文档。</p>
<p>demo源码: <a href="https://link.segmentfault.com/?enc=yxP2BcFUqtf6wJv21OczHg%3D%3D.mtSbVudC6Cep0pQdE%2B4BRIO7SY0n5kpVtmQWea8zECPF%2BWhF4CPXNoY%2Fqfk9R%2B6OACvpbph6IyodFzTbwIxFNEQSuT6p4eZ4Tz503IR3L%2Fo%3D" rel="nofollow">fengxianqi/front_end-demos/src/nuxt-test</a>。</p>
你可能不知道的mpvue性能优化技巧
https://segmentfault.com/a/1190000018892345
2019-04-17T09:38:55+08:00
2019-04-17T09:38:55+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
15
<p>最近一直在折腾<code>mpvue</code>写的微信小程序的性能优化,分享下实战的过程。</p>
<p>先上个优化前后的图:<br><img src="/img/bVbr4AN?w=1561&h=480" alt="图片描述" title="图片描述"><br>可以看到打包后的代码量从<code>813KB</code>减少到<code>387KB</code>,Audits体验评分从<code>B</code>到<code>A</code>,效果还是比较明显的。其实这个指标说明不了什么,而且轻易就可以做到,更重要的是优化<strong>小程序运行过程中的卡顿感</strong>,请耐心往下看。</p>
<h2>常规优化</h2>
<p>常规的Web端优化方法在小程序中也是适用的,而且不可忽视。</p>
<h3>一、压缩图片</h3>
<p>这一步最简单,但是容易被忽视。在<a href="https://link.segmentfault.com/?enc=UgetcgyhvtX2Edp8TAs1KQ%3D%3D.BZnslGrZHgmg%2BEQvtk0oj2qsituROo1Muav36dj3SWs%3D" rel="nofollow">tiny</a>上在线压缩,然后下载替换即可。</p>
<p><img src="/img/bVbr4zr?w=1188&h=917" alt="图片描述" title="图片描述"><br>我这项目的压缩率高达<code>72%</code>,可以说打包后的代码从<code>813KB</code>降到<code>387KB</code>大部分都是归功于压缩图片了。</p>
<h3>二、移除无用的库</h3>
<p>我之前在项目中使用了<a href="https://link.segmentfault.com/?enc=NwTwsbm6RCjm3QujU3DpKA%3D%3D.nK2P6rfK5oTfx2JuDtyTdPlGQSoMzzPW4jFJlqmmP%2BkURPfCzWFPNz%2BBV%2FXFKnSY" rel="nofollow">Vant Weapp</a>,在<code>static</code>目录下引入了整个库,但实际上我只使用了<code>button</code>,<code>field</code>,<code>dialog</code>等几个组件,实在是没必要。</p>
<p>所以干脆移除掉了,微信小程序自身提供的<code>button</code>,<code>wx.showModal</code>等一些组件基本可以满足需求,自己手写一下样式也不用花什么时间。</p>
<p>在这里建议大家,在微信小程序中,<strong>尽量避免使用过多的依赖库</strong>。</p>
<p>不要贪图方便而引入一些比较大的库,小程序不同于<code>Web</code>,限制比较多,能自己写一下就尽量自己写一下吧。</p>
<h2>小程序的优化</h2>
<p>咱们首先得看一下<a href="https://link.segmentfault.com/?enc=c%2Bget9v9o6yzjlC3HyYUXQ%3D%3D.qDX%2F9bwk8h97vRve9YlugIvBuJeDrXivHh958VdJZeIOPGdl8LKfgRojgAtNQtzgweWlT4%2BiZdkT6BpbDr5ZfMOUAf5sekM1MkpTTwOlsvYVM1UrfH49fihfyyOTW1Bi" rel="nofollow">官方优化建议</a>,大多是围绕这个建议去做。</p>
<h3>一、开启Vue.config._mpTrace = true</h3>
<blockquote>这个是<code>mpvue</code>性能优化的一个黑科技啊,可能大多数同学都不知道这个,我在官方文档都没有搜到到这个配置,我真的是服了。<p>我能找到这个配置也是Google机缘巧合下看到的,出处:<a href="https://link.segmentfault.com/?enc=nv2sIQXJy4eN1gS1HN36wA%3D%3D.zFaGq9PgDZVq1IQlNeb%2FzRDalmCpFMn5bXC8Oa6tAJltlGdltp%2Fyc0%2B%2BokA3eOvE" rel="nofollow">mpvue重要更新,页面更新机制进行全面升级</a><br>具体做法是在<code>/src/main.js</code>添加<code>Vue.config._mpTrace = true</code>,如:</p>
</blockquote>
<pre><code>Vue.config._mpTrace = true
Vue.config.productionTip = false
App.mpType = 'app'</code></pre>
<p>添加了<code>Vue.config._mpTrace</code>属性,这样就可以看到console里会打印每500ms更新的数据量。如图:<br><img src="/img/bVbr4zq?w=546&h=224" alt="图片描述" title="图片描述"><br><strong>如果数据更新量很大,会明显感觉小程序运行卡顿,反之就流畅</strong>。因此我们可以根据这个指标,逐步找出性能瓶颈并解决掉。</p>
<h3>二、精简data</h3>
<h4>1. 过滤api返回的冗余数据</h4>
<p>后端的api可能是需要同时为iOS,Android,H5等提供服务的,往往会有些冗余的数据小程序是用不到的。比如api返回的一个<code>文章列表</code>数据有很多字段:</p>
<pre><code>this.articleList = [
{
articleId: 1,
desc: 'xxxxxx',
author: 'fengxianqi',
time: 'xxx',
comments: [
{
userId: 2,
conent: 'xxx'
}
]
},
{
articleId: 2
// ...
},
// ...
]</code></pre>
<p><strong>假设</strong>我们在小程序中只需要用到列表中的部分字段,如果不对数据做处理,将整个<code>articleList</code>都<code>setData</code>进去,是不明智的。</p>
<blockquote>小程序官方文档: <a href="https://link.segmentfault.com/?enc=Utj2SyuZvHImvuWswx%2BnLQ%3D%3D.OffLFO4NHfW3W3JYWgMO0OpAUGqrOKTaQWC8A9U4K4fWxG3JTYX4RnuzfHUNiavstCk8%2F6%2BkhHbZvP9%2FCukpuYo82UsdYOBV%2BmazHdCPaBc%3D" rel="nofollow">单次设置的数据不能超过1024kB,请尽量避免一次设置过多的数据</a>。</blockquote>
<p>可以看出,内存是很宝贵的,当<code>articleList</code>数据量非常大超过1M时,某些机型就会爆掉(我在iOS中遇到过很多次)。</p>
<p>因此,需要将接口返回的数据剔除掉不需要的,再<code>setData</code>,回到我们上面的<code>articleList</code>例子,假设我们只需要用<code>articleId</code>和<code>author</code>这两个字段,可以这样:</p>
<pre><code>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
// 需要哪些字段就加上哪些字段
}
})
}
}
}</code></pre>
<p>这里我们将返回的数据通过<code>simplifyArticleList </code>来精简数据,此时过滤后的<code>articleList</code>中的数据类似:</p>
<pre><code>[
{articleId: 1, author: 'fengxianqi'},
{articleId: 2, author: 'others'}
// ...
]</code></pre>
<p>当然,如果你的需求中是所有数据都要用到(或者大部分数据),就没必要做一层精简了,收益不大。毕竟精简数据的函数中具体的字段,是会增加维护成本的。</p>
<blockquote>PS: 在我个人的实际操作中,做数据过滤虽然增加了维护的成本,但一般收益都很大,因次这个方法比较推荐。</blockquote>
<h4>2. data()中只放需要的数据</h4>
<pre><code>import xx from 'xx.js'
export default {
data () {
return {
xx,
otherXX: '2'
}
}
}</code></pre>
<p>有些同学可能会习惯将<code>import</code>的东西都先放进<code>data</code>中,再在<code>methods</code>中使用,在小程序中<strong>可能</strong>是个不好的习惯。</p>
<p>因为通过<code>Vue.config._mpTrace = true</code>在更新某个数据时,我对比放进data和不放进data中的两种情况会有差别。</p>
<p>所以我猜测可能是data是会一起更新的,比如只是想更新<code>otherXX</code>时,会同时将<code>xx</code>也一起合起来<code>setData</code>了。</p>
<h4>3. 静态图片放进static</h4>
<p>这个问题和上面的问题其实是一样的,有时候我们会通过<code>import</code>的方式引入,比如这样:</p>
<pre><code><template>
<img :src="UserIcon">
</template>
<script>
import UserIcon from '@/assets/images/user_icon.png'
export default {
data () {
return {
UserIcon
}
}
}
</script></code></pre>
<p>这样会导致打包后的代码,图片是<code>base64</code>形式(很长的一段字符串)存放在<code>data</code>中,不利于精简data。同时当该组件多个地方使用时,每个组件实例都会携带这一段很长的<code>base64</code>代码,进一步导致数据的冗余。</p>
<p>因此,建议将静态图片放到<code>static</code>目录下,这样引用:</p>
<pre><code><template>
<img src="/static/images/user_icon.png">
</template></code></pre>
<p>代码也更简洁清爽。</p>
<p>看一下做了上面操作的前后对比图,使用体验上也流畅了很多。<br><img src="/img/bVbr4zp?w=1159&h=378" alt="图片描述" title="图片描述"></p>
<h3>三、swiper优化</h3>
<p>小程序自身提供的<code>swiper</code>组件性能上不是很好,使用时要注意。参考着两个思路:</p>
<ul>
<li><a href="https://link.segmentfault.com/?enc=Djhslcb0yM9Pu8gPvSALuw%3D%3D.WEsR39MeO16SOvovdxOOwSESLlYUcwjfhCB8SEe3UMaiEHBSVEmwvidB5kDdMohbMgQ459OQIhdiI4losL0U8g4PaYMbbW6sueSz5u0YC2YAat01L%2FUJP%2B5K6TcStJXb" rel="nofollow">【优化】解决swiper渲染很多图片时的卡顿</a></li>
<li><a href="https://link.segmentfault.com/?enc=ygoaZT9I%2BXwNlTs7vXwjSw%3D%3D.FyQ7K6eC1duk1wHeUSfWlbMhma5Ml5kMQNqE5UxC9UUisgL%2BLQDI4enpt3eXrTr61PayRuXaFTBJYcx0v4%2FOjQ%3D%3D" rel="nofollow">想请教一下小程序swiper组件的问题</a></li>
</ul>
<p>在我使用时,由于需求原因,动态删掉swiper-item的思路不可行(手滑时会造成抖动)。因此只能作罢。但仍然可以优化一下:</p>
<ul><li>将未显示的<code>swiper-item</code>中的图片用<code>v-if</code>隐藏到,当判断到current时才显示,防止大量图片的渲染导致的性能问题。</li></ul>
<h3>四、vuex使用注意事项</h3>
<p>我之前写过的一篇<a href="https://segmentfault.com/a/1190000018470280#articleHeader4">mpvue开发音频类小程序踩坑和建议</a>里面有讲如何在小程序中使用<code>vuex</code>。但遇到了个比较严重的性能问题。</p>
<h4>1. 问题描述</h4>
<p>我开发的是一个音频类的小程序,所以需要将播放列表<code>playList</code>,当前索引<code>currentIndex</code>和当前时长<code>currentTime</code>放进<code>state.js</code>中:</p>
<pre><code>const state = {
currentIndex: 0, // playList当前索引
currentTime: 0, // 当前播放的进度
playList: [], // {title: '', url: '', singer: ''}
}</code></pre>
<p>每次用户点击播放音频时,都会先加载音频的播放列表<code>playList</code>,然后播放时更新当前时长<code>currentTime</code>,发现<strong>有时候播音频时整个小程序非常卡顿</strong>。</p>
<blockquote>注意到,音频需每秒就得更新一次<code>currentTime</code>,即每秒就做一次<code>setData</code>操作,稍微有些卡顿是可以理解的。但我发现是播放列表数据比较多时会特别卡,比如playList的长度是100条以上时。</blockquote>
<h4>2. 问题原因</h4>
<p>我开启<code>Vue.config._mpTrace = true</code>后发现一个规律:</p>
<p>当<code>palyList</code>数据量小时,<code>console</code>显示造成的数据量更新数值比较小;当<code>playList</code>比较大时,<code>console</code>显示造成的数据量更新数值比较大。</p>
<blockquote>PS: 我曾尝试将playList数据量增加到200条,每500ms的数据量更新达到800KB左右。</blockquote>
<p>到这里基本可以确定一个事实就是:<strong>更新<code>state</code>中的任何一个字段,将导致整个<code>state</code>全量一起<code>setData</code></strong>。在我这里的例子,虽然我每次只是更新<code>currentTime</code>这个字段的值,但依然导致将state中的其他字段如<code>playList</code>,<code>currentIndex</code>都一起做了一次<code>setData</code>操作。</p>
<h4>3. 解决问题</h4>
<p>有两个思路:</p>
<ul>
<li>精简state中保存的数据,即限制<code>playList</code>的数据不能太多,可将一些数据暂存在<code>storage</code>中</li>
<li>
<code>vuex</code>采用<code>Module</code>的写法能改善这个问题,虽然使用时命名空间造成一定的麻烦。<a href="https://link.segmentfault.com/?enc=fQhLQgbHHFIn0EIle2LCFg%3D%3D.iAommCLIyCpCgXf0oV5UXLFo8fK4dN3AwrxAeZuChJ9lsl%2B9l4chJqgCF2a87zgQ" rel="nofollow">vuex传送门</a>
</li>
</ul>
<p>一般情况下,推荐使用<strong>后者</strong>。我在项目中尝试使用了前者,同样能达到很好的效果,请继续看下面的分享。</p>
<h3>五、善用storage</h3>
<h4>1.为什么说要善用storage</h4>
<p>由于小程序的内存非常宝贵,占用内存过大会非常卡顿,因此最好尽可能少的将数据放到内存中,即<code>vuex</code>存的数据要尽可能少。而小程序的<code>storage</code>支持单个 key允许存储的最大数据长度为 <code>1MB</code>,所有数据存储上限为 <code>10MB</code>。</p>
<p>所以可以将一些相对取用不频繁的数据放进<code>storage</code>中,需要时再将这些数据放进内存,从而缓解内存的紧张,有点类似Windows中<code>虚拟内存</code>的概念。</p>
<h4>2.storage换内存的实例</h4>
<blockquote>这个例子讲的会有点啰嗦,真正能用到的朋友可以详细看下。</blockquote>
<p>上面讲到<code>playList</code>数据量太多,播放一条音频时其实只需要最多保证3条数据在内存中即可,即<code>上一首</code>,<code>播放中的</code>,<code>下一首</code>,我们可以将多余的播放列表存放在<code>storage</code>中。</p>
<blockquote>PS: 为了保证更平滑地连续切换下一首,我们可以稍微保存多几条,比如我这里选择保存5条数据在vuex中,播放时始终保证当前播放的音频前后都有两条数据。</blockquote>
<pre><code>// 首次播放背景音频的方法
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)
}</code></pre>
<p>重点是<code>subPlayList</code>方法,这个方法保证了拿到的播放列表是<strong>最多5条数据</strong>。</p>
<pre><code>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
}</code></pre>
<p><code>tempArr.splice(Math.max(0, index - middle), count)</code>可能有些同学比较难理解,需要仔细琢磨一下。假设<code>playList</code>有10条数据:</p>
<ul>
<li>当前音频是列表中的第1条(索引是0),截取前5个:<code>playList.splice(0, 5)</code>,此时<code>currentAudio</code>在这5个数据的索引是<code>0</code>,没有<code>上一首</code>,有4个<code>下一首</code>
</li>
<li>当前音频是列表中的第2条(索引是1),截取前5个:<code>playList.splice(0, 5)</code>,此时<code>currentAudio</code>在这5个数据的索引是<code>1</code>,有1个<code>上一首</code>,3个<code>下一首</code>
</li>
<li>当前音频是列表中的第3条(索引是2),截取前5个:<code>playList.splice(0, 5)</code>,此时<code>currentAudio</code>在这5个数据的索引是<code>2</code>,有2个<code>上一首</code>,2个<code>下一首</code>
</li>
<li>当前音频是列表中的第4条(索引是3),截取第1到6个:<code>playList.splice(1, 5)</code>
</li>
</ul>
<p>,此时<code>currentAudio</code>在这5个数据的索引是<code>2</code>,有2个<code>上一首</code>,2个<code>下一首</code></p>
<ul>
<li>当前音频是列表中的第5条(索引是4),截取第2到7个:<code>playList.splice(2, 5)</code>,此时<code>currentAudio</code>在这5个数据的索引是<code>2</code>,有2个<code>上一首</code>,2个<code>下一首</code>
</li>
<li>...</li>
<li>当前音频是列表中的第9条(索引是<code>8</code>),截取后5个:<code>playList.splice(4, 5)</code>,此时<code>currentAudio</code>在这5个数据的索引是<code>3</code>,有3个<code>上一首</code>,1个<code>下一首</code>
</li>
<li>当前音频是列表中的最后1条(索引是<code>9</code>),截取后的5个:<code>playList.splice(4, 5)</code>,此时<code>currentAudio</code>在这5个数据的索引是<code>4</code>,有4个<code>上一首</code>,没有<code>下一首</code>
</li>
</ul>
<p>有点啰嗦,感兴趣的同学仔细琢磨下,无论当前音频在哪,都始终保证了拿到当前音频前后的最多5条数据。</p>
<p>接下来就是维护播放上一首或下一首时保证当前vuex中的<code>playList</code>始终是包含当前音频的前后2条。</p>
<h5>播放下一首</h5>
<pre><code class="javascript">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)
}
}
}</code></pre>
<p>这里的<code>getPlayList</code>方法是上面讲过的,本来是从api中直接获取的,为了避免每次都从api直接获取,所以需要改一下,先读storage,若无则从api获取:</p>
<pre><code>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)
}
}</code></pre>
<p>播放上一首也是同理,就不赘述了。</p>
<blockquote>PS: 将vuex中的数据精简后,我所做的小程序在播放音频时刷其他页面已经非常流畅啦,效果非常好。</blockquote>
<h3>六、动画优化</h3>
<p>这个问题在<a href="https://segmentfault.com/a/1190000018470280#articleHeader14">mpvue开发音频类小程序踩坑和建议</a>已经讲过了,感兴趣的可以移步看一眼,这里只写下概述:</p>
<ul>
<li>如果要使用动画,尽量用css动画代替wx.createAnimation</li>
<li>使用css动画时建议开启硬件加速</li>
</ul>
<h2>最后</h2>
<p>大致总结一下上面所讲的几个要点:</p>
<ul>
<li>开发时打开<code>Vue.config._mpTrace = true</code>。</li>
<li>谨慎引入第三方库,权衡收益。</li>
<li>添加数据到data中时要克制,能精简尽量精简。</li>
<li>图片记得要压缩,图片在显示时才渲染。</li>
<li>vuex保持数据精简,必要时可先存storage。</li>
</ul>
<p>性能优化是一个永不止步的话题,我也还在摸索,不足之处还请大家指点和分享。</p>
<p>欢迎关注,会持续分享前端实战中遇到的一些问题和解决办法。</p>
用Class写一个记住用户离开位置的js插件
https://segmentfault.com/a/1190000018569836
2019-03-19T11:44:12+08:00
2019-03-19T11:44:12+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
15
<h2>前言</h2>
<p>常见的js插件都很少使用ES6的<code>class</code>,一般都是通过构造函数,而且常常是手写<code>CMD</code>、<code>AMD</code>规范来封装一个库,比如这样:</p>
<pre><code>// 引用自:https://www.jianshu.com/p/e65c246beac1
;(function(undefined) {
"use strict"
var _global;
var plugin = {
// ...
}
_global = (function(){ return this || (0, eval)('this'); }());
if (typeof module !== "undefined" && module.exports) {
module.exports = plugin;
} else if (typeof define === "function" && define.amd) {
define(function(){return plugin;});
} else {
!('plugin' in _global) && (_global.plugin = plugin);
}
}());</code></pre>
<p>但现在都9102年了,是时候祭出我们的ES6大法了,可以用更优雅的的写法来实现一个库,比如这样:</p>
<pre><code class="javascript">class RememberScroll {
constructor(options) {
...
}
}
export default RememberScroll</code></pre>
<p>在这篇文章,博主主要通过分享最近自己写的一个<a href="https://link.segmentfault.com/?enc=a6FTZ7K%2BU6QObxpMZqw04Q%3D%3D.ENu9vl51LgBgPpFi9C65KJ2%2BY41OFLMxfAs%2B3Tx98T%2FdfhbLy%2F9mbmg49U7t5sKJ" rel="nofollow">记住页面滚动位置</a>小插件,讲一下如何用<code>class</code>语法配合<code>webpack 4.x</code>和<code>babel 7.x</code>封装一个可用的库。</p>
<p>项目地址:<a href="https://link.segmentfault.com/?enc=TZ097a27MheVcxd40xtxcQ%3D%3D.k64UDVqNpyEY%2BQ64u3mp0CiSl16xVSJwrfl9GfsKJzIxnvrls0UAj3r4onRbwtIa" rel="nofollow">Github</a>, 在线Demo:<a href="https://link.segmentfault.com/?enc=TIityWD6R9UQCexIoAdoZw%3D%3D.qStinjO0XSKiSXmPn9i8OH31yXM%2Br3MOca6kZdbsBmhcf53GPikDmG0Cv5o%2FyI7JWpOhnsiglOcxdzuuCoEGvw%3D%3D" rel="nofollow">Demo</a></p>
<p>喜欢的朋友希望能点个<a href="https://link.segmentfault.com/?enc=mqVugH3Y4jVPvoYiQpk%2ByA%3D%3D.0PGfUT1Ne%2BUPjQVDOYuRxR275tVh2CvqCYMVdxW7N5y1vg%2BlH%2BWJ8c6BCVa5PPN7" rel="nofollow">Star</a>收藏一下,非常感谢。</p>
<h2>需求来源</h2>
<p>相信很多同学都会遇到这样一个需求:<strong>用户浏览一个页面并离开后,再次打开时需要重新定位到上一次离开的位置</strong>。</p>
<p>这个需求很常见,我们平时在手机上阅读微信公众号的文章页面就有这个功能。想要做到这个需求,也比较好实现,但博主有点懒,心想有没有现成的库可以直接用呢?于是去GitHub上搜了一波,发现并没有很好的且符合我需求的,于是得自己实现一下。</p>
<p>为了灵活使用(只是部分页面需要这个功能),博主在项目中单独封装了这个库,本来是在公司项目中用的,后来想想何不开源出来呢?于是有了这个分享,这也是对自己工作的一个总结。</p>
<h2>预期效果</h2>
<p>博主喜欢在做一件事情前先yy一下预期的效果。博主希望这个库用起来尽量简单,最好是插入一句代码就可以了,比如这样:</p>
<pre><code><html>
<head>
<meta charset="utf-8">
<title>remember-scroll examples</title>
</head>
<body>
<div id="content"></div>
<script src="../dist/remember-scroll.js"></script>
<script>
new RememberScroll()
</script>
</body>
</html></code></pre>
<p>在想要加上记住用户浏览位置的页面上引入一下库,然后<code>new RememberScroll()</code>初始化一下即可。</p>
<p>下面就带着这个目标,一步一步去实现啦。</p>
<h2>设计方案</h2>
<h3>1. 需要存哪些信息?</h3>
<p>用户浏览页面的位置,主要需要存两个字段:<code>哪个页面</code>和<code>离开时的位置</code>,通过这两个字段,我们才可以在用户第二次打开网站的页面时,命中该页面,并自动跳转到上一次离开的位置。</p>
<h3>2.存在哪?</h3>
<p>记住浏览位置,需要将用户离开前的浏览位置记录在客户端的浏览器中。这些信息可以主要存放在:<code>cookie</code>、<code>sessionStorage</code>、<code>localStorage</code>中。</p>
<ol>
<li>存放在<code>cookie</code>,大小4K,空间虽有限但也勉强可以。但cookie是每次请求服务器时都会携带上的,无形中增加了带宽和服务器压力,所以总体来说是不太合适的。</li>
<li>存放在<code>sessionStorage</code>中,由于仅在当前会话下有效,用户离开页面<code>sessionStorage</code>就会被清除,所以不能满足我们的需求。</li>
<li>存放在<code>localStorage</code>,浏览器可永久保存,大小一般限制5M,满足我们需求。</li>
</ol>
<p>综上,最后我们应该选择<code>localStorage</code>。</p>
<h3>3. 需注意的问题</h3>
<ol><li>一个站点可能有很多页面,<strong>如何标识是哪个页面呢?</strong>
</li></ol>
<p>一般来说可以用页面的url作为页面的唯一标识,比如:<code>www.xx.com/article/${id}</code>,不同的id对应不同的页面。</p>
<p>但博主考虑到现在很多站点都是用spa了,而且常见在url后面会带有<code>#xxx</code>的哈希值,如<code>www.xx.com/article/${id}#tag1</code>和<code>www.xx.com/article/${id}#tag2</code>这种情况,这可能表示的是同一个页面的不同锚点,所以用url作为页面的唯一标识不太可靠。</p>
<p>因此,博主决定将这个<strong>页面唯一标识</strong>作为一个参数来让使用者来决定,姑且命名为<code>pageKey</code>,让使用者保证是全站唯一的即可。</p>
<ol><li>如果用户访问我们的站点中很多很多的页面,由于<code>localStorage</code>是永久保存的,<strong>如何避免localStorage不断累积占用过大?</strong>
</li></ol>
<p>我们的需求可能仅仅是想近期记住即可,即只需要记住用户的浏览位置几天,可能会更希望我们存的数据能够自动过期。</p>
<p>但<code>localStorage</code>自身是没有自动过期机制的,一般只能在存数据的时候同时存一下时间戳,然后在使用时判断是否过期。如果只能是在使用时才判断是否清除,而新访问页面时又会生成新的记录,<code>localStorage</code>中始终都会存在至少一条记录的,也就是说无法真正实现自动过期。这里不禁就觉得有点多余了,既然都是会一直保留记录在<code>localStorage</code>中,那干脆就不判断了,咱换一个思路:<strong>只记录有限的最新页面数量</strong>。</p>
<p>举个例子:</p>
<blockquote>咱们网站有个文章页:<code>www.xx.com/articles/${id}</code>,每个的<code>id</code>表示不同的文章,咱们只记录用户最新访问的5篇文章,即维护一个长度为5的队列。<p>比如当前网站有id从<code>1</code>到<code>100</code>篇文章,用户分别访问第<code>1,2,3,4,5</code>篇文章时,这5篇文章都会记录离开的位置,而当用户打开第六篇文章时,第六条记录入队的同时第一条记录出队,此时<code>localStorage</code>中记录的是<code>2,3,4,5,6</code>这几篇文章的位置,这就保证了<code>localStorage</code>永远不会累积存储数据且旧记录会随着不断访问新页面自动“过期”。</p>
</blockquote>
<p>为了更灵活一点,博主决定给这个插件添加一个<code>maxLength</code>的参数,表示当前站点下记录的最新的页面最大数量,默认值设为<code>5</code>,如果有小伙伴的需求是记录更多的页面,可以通过这个参数来设置。</p>
<h3>4. 实现思路</h3>
<ol>
<li>我们需要时刻监听用户浏览页面时的滚动条的位置,可以通过<code>window.onscroll</code>事件,获得当前的滚动条位置:<code>scrollTop </code>。</li>
<li>将<code>scrollTop</code>和页面唯一标识<code>pageKey</code>存进<code>localStorage</code>中。</li>
<li>用户再次打开之前访问过的页面,在页面初始化时,读取<code>localStorage</code>中的数据,判断页面的<code>pageKey</code>是否一致,若一致则将页面的滚动条位置自动滚动到相应的<code>scrollTop</code>值。</li>
</ol>
<blockquote>是不是很简单?不过实现的过程中需要注意一下细节,比如做一下防抖处理。</blockquote>
<h2>实现步骤</h2>
<p>逼逼了这么久,是时候开始撸代码了。</p>
<h3>1.封装<code>localStorage</code>工具方法</h3>
<p>工欲善其事,必先利其器。为更好服务接下来的工作,咱们先简单封装一下调用<code>localStorage</code>的几个方法,主要是<code>get</code>,<code>set</code>,<code>remove</code>:</p>
<pre><code>// storage.js
const Storage = {
isSupport () {
if (window.localStorage) {
return true
} else {
console.error('Your browser cannot support localStorage!')
return false
}
},
get (key) {
if (!this.isSupport) {
return
}
const data = window.localStorage.getItem(key)
return data ? JSON.parse(data) : undefined
},
remove (key) {
if (!this.isSupport) {
return
}
window.localStorage.removeItem(key)
},
set (key, data) {
if (!this.isSupport) {
return
}
const newData = JSON.stringify(data)
window.localStorage.setItem(key, newData)
}
}
export default Storage</code></pre>
<h3>2. class大法</h3>
<p><code>class</code>即类,本质上虽然是一个<code>function</code>,但使用<code>class</code>定义一个类会更直观。咱们为即将写的库起个名字为<code>RememberScroll</code>,开始就是如下的样子啦:</p>
<pre><code>import Storage from './storage'
class RememberScroll {
constructor() {
}
}</code></pre>
<p>1.处理传进来的参数</p>
<p>我们需要在类的构造函数<code>constructor</code>中接收参数,并覆盖默认参数。</p>
<p>还记得上面咱们预期的用法吗?即<code>new RememberScroll({pageKey: 'myPage', maxLength: 10})</code>。</p>
<pre><code> constructor (options) {
let defaultOptions = {
pageKey: '_page1', // 当前页面的唯一标识
maxLength: 5
}
this.options = Object.assign({}, defaultOptions, options)
}</code></pre>
<p>如果没有传参数,就会使用默认的参数,如果传了参数,就使用传进来的参数。<code>this.options</code>就是最终处理后的参数啦。</p>
<p>2.页面初始化</p>
<p>当页面初始化时,咱们需要做三件事情:</p>
<ul>
<li>从<code>loaclStorage</code>取出缓存列表</li>
<li>将滚动条滚动到记录的位置(若有记录的话);</li>
<li>注册<code>window.onscroll</code>事件监听用户滚动行为;</li>
</ul>
<p>因此,需要在构造函数中就执行<code>initScroll</code>和<code>addScrollEvent</code>这两个方法:</p>
<pre><code>import Storage from './utils/storage'
class RememberScroll {
constructor (options) {
// ...
this.storageKey = '_rememberScroll'
this.list = Storage.get(this.storageKey) || []
this.initScroll()
this.addScrollEvent()
}
initScroll () {
// ...
}
addScrollEvent () {
// ...
}
}</code></pre>
<p>这里咱们将<code>localStorage</code>中的键名命名为<code>_rememberScroll</code>,应该能够尽量避免和平常站点使用localStorage的键名冲突。</p>
<p>3.监听滚动事件:addScrollEvent()的实现</p>
<pre><code> addScrollEvent () {
window.onscroll = () => {
// 获取最新的位置,只记录垂直方向的位置
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
// 构造当前页面的数据对象
const data = {
pageKey: this.options.pageKey,
y: scrollTop
}
let index = this.list.findIndex(item => item.pageKey === data.pageKey)
if (index >= 0) {
// 之前缓存过该页面,则替换掉之前的记录
this.list.splice(index, 1, data)
} else {
// 如果已经超出长度了,则清除一条最早的记录
if (this.list.length >= this.options.maxLength) {
this.list.shift()
}
this.list.push(data)
}
// 更新localStorage里面的记录
Storage.set(this.storageKey, this.list)
}
}</code></pre>
<blockquote>ps:这里最好需要做一下防抖处理</blockquote>
<p>4.初始化滚动条位置: initScroll()的实现</p>
<pre><code>initScroll () {
// 先判断是否有记录
if (this.list.length) {
// 当前页面pageKey是否一致
let currentPage = this.list.find(item => item.pageKey === this.options.pageKey)
if (currentPage) {
setTimeout(() => {
// 一致,则滚动到对应的y值
window.scrollTo(0, currentPage.y)
}, 0)
}
}</code></pre>
<p>细心的同学可能会发现,这里用了setTimeout,而不是直接调用<code>window.scrollTo</code>。这是因为博主在这里遇到坑了,这里涉及到页面加载执行顺序的问题。</p>
<p>在执行<code>window.scrollTo</code>前,页面必须是已经加载完成了的,滚动条要已存在才可以滚动对吧。如果页面加载时直接执行,当时的scroll高度可能为0,<code>window.scrollTo</code>执行就会无效。如果页面的数据是异步获取的,也会导致<code>window.scrollTo</code>无效。因此用<code>setTimeout</code>会是比较稳的一个办法。</p>
<p>5.将模块export出去</p>
<p>最后我们需要将模块export出去,整体代码大概是这个样子:</p>
<pre><code>import Storage from './utils/storage'
class RememberScroll {
constructor (options) {
let defaultOptions = {
pageKey: '_page1', // 当前页面的唯一标识
maxLength: 5
}
this.storageKey = '_rememberScroll'
// 参数
this.options = Object.assign({}, defaultOptions, options)
// 缓存列表
this.list = Storage.get(this.storageKey) || []
this.initScroll()
this.addScrollEvent()
}
initScroll () {
// ...
}
addScrollEvent () {
// ...
}
}
export default RememberScroll</code></pre>
<p>这样就基本完成整个插件的功能啦,是不是很简单哈哈。篇幅原因就不贴具体代码了,可以直接到GitHub上看:<a href="https://link.segmentfault.com/?enc=jPoMqQTwEVB0lSxRq%2FKBnA%3D%3D.maOq2%2FCCPoIOHtxXrlAxNZh%2FUKY%2Fd5hsxkuZzXMXqRUEUkPj4m%2B8PBJbWS%2FJ47GcJj3lRxXE%2B40dIEDU3cnAk9oCOTQYVyaZ7XmuUrtGm5c%3D" rel="nofollow">remember-scroll</a></p>
<h2>打包</h2>
<p>接下来应该是本文的重点了,首先要清楚为什么要打包?</p>
<ol>
<li>将项目中所用到的js文件合并,只对外输出一个js文件。</li>
<li>使项目同时支持<code>AMD</code>,<code>CMD</code>、浏览器<code><script></code>标签引入,即<code>umd</code>规范。</li>
<li>配合babel,将es6语法转为es5语法,兼容低版本浏览器。</li>
</ol>
<blockquote>PS: 由于webpack和babel更新速度很快,网上很多教程可能早已过时,现在(2019-03)的版本已经是babel 7.3.0,webpack 4.29.6, 本篇文章只分享现在的最新的配置方法,因此本篇文章也是会过时的,读者们请注意版本号。</blockquote>
<h3>npm init项目</h3>
<p>咱们先新建一个目录,这里名为:<code>remember-scroll</code>,然后将上面写好的<code>remember-scroll.js</code>放进<code>remember-scroll/src/</code>目录下。</p>
<blockquote>PS:一般项目的资源文件都放在src目录下,为了显得专业点,最好将<code>remember-scroll.js</code>改名为<code>index.js</code>。)</blockquote>
<p>此时项目还没有<code>package.json</code>文件,因此在根目录执行命令初始化package.json:</p>
<pre><code>npm init</code></pre>
<p>需要根据提示填写一些项目相关信息。</p>
<h3>安装webpack和webpack-cli</h3>
<p>运行webpack命令时需要同时装上<code>webpack-cli</code>:</p>
<pre><code>npm i webpack webpack-cli -D</code></pre>
<h3>配置webpack.config.js</h3>
<p>在根目录中添加一个<code>webpack.config.js</code>,按照<a href="https://link.segmentfault.com/?enc=pex1UX28LZran1W7o2IzxQ%3D%3D.dvIAKbjQCjRUeGRB8IYaaMH6mWNsadXi6lP1R9tShok%3D" rel="nofollow">webpack官网</a>的示例代码配置:</p>
<pre><code>const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'remember-scroll.js' // 修改下输出的名称
}
};</code></pre>
<p>然后在<code>package.json</code>的script中配置运行<code>webpack</code>的命令:</p>
<pre><code> "scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack --mode=development --colors"
},</code></pre>
<p>这样配置完成,在根目录运行<code>npm run dev</code>,会自动生成<code>dist/remember-scroll.js</code>。</p>
<p>此时已经实现了我们的第一个小目标:赚它一个亿,哦不,是将<code>storage.js</code>和<code>index.js</code>合并输出为一个<code>remember-scroll.js</code>。</p>
<blockquote>这种简单的打包可以称为:<code>非模块化打包</code>。由于我们在js文件中没有通过<code>AMD</code>的return或者<code>CommonJS</code>的exports或者this导出模块本身,导致模块被引入的时候只能执行代码而无法将模块引入后赋值给其它模块使用。</blockquote>
<h3>支持umd规范</h3>
<p>相信很多同学都听过<code>AMD</code>,<code>CommonJS</code>规范了,不清楚的同学可以看看阮一峰老师的介绍:<a href="https://link.segmentfault.com/?enc=b7c2%2FjhvNPiRi0jjvoDDJw%3D%3D.uSEkJGB5SuYKR9E621mzHPYGm0%2BheXhdod8MFr7w7cY1ke6VkbKEUBVcrjKotr0PcMq%2BxOaU7UUvMgrZFm9n%2B%2B%2FxmCo%2FxZgHjm%2F0b5I724k%3D" rel="nofollow">Javascript模块化编程(二):AMD规范</a>。</p>
<p>为了让我们的插件同时支持<code>AMD</code>,<code>CommonJS</code>,所以需要将我们的插件打包为<code>umd</code>通用模块。</p>
<p>之前看过一篇文章:<a href="https://link.segmentfault.com/?enc=wadBCLRCCT7U3ARUXJTEDQ%3D%3D.6cduj5mpKKX3yg8ixeDNXKRLRGEwL%2FsEPStlQNFV9guQziH72cL3EY9comPJHCbr" rel="nofollow">如何定义一个高逼格的原生JS插件</a>,在没有使用webpack打包时,需要在插件中手写支持这些模块化的代码:</p>
<pre><code>// 引用自:https://www.jianshu.com/p/e65c246beac1
;(function(undefined) {
"use strict"
var _global;
var plugin = {
// ...
}
// 最后将插件对象暴露给全局对象
_global = (function(){ return this || (0, eval)('this'); }());
if (typeof module !== "undefined" && module.exports) {
module.exports = plugin;
} else if (typeof define === "function" && define.amd) {
define(function(){return plugin;});
} else {
!('plugin' in _global) && (_global.plugin = plugin);
}
}());</code></pre>
<p>博主看到这坨东西,也是有点晕,不得不佩服大佬就是大佬。还好现在有了<code>webpack</code>,我们现在只需要写好主体关键代码,<code>webpack</code>会帮我们处理好这些打包的问题。</p>
<p>在webpack4中,我们可以将js打包为一个库的形式,详情可看:[Webpack Expose the Library<br>](<a href="https://link.segmentfault.com/?enc=WOtUJwwhx6IWr0ZLbsa8%2Bg%3D%3D.fTlBDFmIHo6OOFL%2BgU%2FjMe%2B6%2BdCQMlRPPRTSp%2Fw4m82k%2FG4s5sXzmn3rpfZowF8ZJcVN%2BxjcpMGi7FNC921lHSqSDPFCBdYFWdj9saFbOAU%3D" rel="nofollow">https://webpack.js.org/guides...</a>。在我们这里只需在output中加上library属性:</p>
<pre><code>const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'remember-scroll.js',
library: 'RememberScroll',
libraryTarget: 'umd',
libraryExport: 'default'
}
};</code></pre>
<p>注意<code>libraryTarget</code>为<code>umd</code>,就是我们要打包的目标规范为<code>umd</code>。</p>
<p>当我们在html中通过script标签引入这个js时,会在window下注册<code>RememberScroll</code>这个变量(类似引入<code>jQuery</code>时会在全局注册<code>$</code>这个变量)。此时就直接使用<code>RememberScroll</code>这个变量了。</p>
<pre><code><script src="../dist/remember-scroll.js"></script>
<script>
console.log(RememberScroll)
</script></code></pre>
<p>这里有个坑需要注意一下,如果没有加上<code>libraryExport: 'default'</code>,由于我们代码中是<code>export default RememberScroll</code>,打包出来的代码会类似:</p>
<pre><code>{
'default': {
initScroll () {}
}
}</code></pre>
<p>而我们期望的是这样:</p>
<pre><code>{
initScroll () {}
}</code></pre>
<p>即我们希望的是直接输出<code>default</code>中的内容,而不是隔着一层<code>default</code>。所以这里还要加上<code>libraryExport: 'default'</code>,打包时只输出<code>default</code>的内容。</p>
<blockquote>PS: webpack英文文档看得有点懵逼,这个坑让博主折腾了很久才爬起来,所以特别讲下。刚兴趣的同学可以看下文档:<a href="https://link.segmentfault.com/?enc=nnOJOT9O0hi7SOcOkeklow%3D%3D.0H2p8Hil2sPU88cC1fMQeXSGY4K7aLoXk7WNQgbJP%2FEEtTNKofSrAd0SzKZsoBR4f58n5OB5bQGVoe4Y5GEzFuJLKzw0oMQ1ieZ1e1zlFQo%3D" rel="nofollow">output.libraryExport</a>。</blockquote>
<p>到这里,已经实现了我们的第二个小目标:<strong>支持umd规范</strong>。</p>
<h3>使用babel-loader</h3>
<p>上面我们打包出来的js,其实已经可以正常运行在支持es6语法的浏览器中了,比如chrome。但想要运行在IE10,IE11中,还得让神器<a href="https://link.segmentfault.com/?enc=au5urqGGbl4KZCG7sKcgiA%3D%3D.OWQbC2fpd1i%2BHMu5PCxb4RqyNxtrU2B0%2FbPUwjYSRC4%3D" rel="nofollow">Babel</a>帮我们一把。</p>
<blockquote>PS: 虽然很多人说不考虑兼容IE了,但作为一个通用性的库,古董级的IE7,8,9可以不兼容,但较新版本的IE10,11还是需要兼容一下的。</blockquote>
<p><code>Babel</code>是一个JavaScript转译器,相信大家都听过。由于JavaScript在不断的发展,但是浏览器的发展速度跟不上,新的语法和特性不能马上被浏览器支持,因此需要一个能将新语法新特性转为现代浏览器能理解的语法的转译器,而Babel就是充当了转译器的角色。</p>
<blockquote>PS:以前博主一直以为(相信很多刚接触Babel的同学也是这样),只要使用了Babel,就可以放心无痛使用ES6的语法了,然而事情并不是这样。<strong>Babel编译并不会做polyfill</strong>,Babel为了保证正确的语义,只能转换语法而不会增加或修改原有的属性和方法。要想无痛使用ES6,还需要配合polyfill。不太理解的同学,在这里推荐大家看下这篇文章:<a href="https://link.segmentfault.com/?enc=jqKAQjrR%2FTncnmyiuMUcJQ%3D%3D.YF%2BUfypqLxRJRRY%2FFF8dHM%2B1YLeVsOivtyjRLxQjvidZtmIIC44X5oLa%2BcgESNIP" rel="nofollow">21 分钟精通前端 Polyfill 方案</a>,写得非常通俗易懂。</blockquote>
<p>总的来说,就是Babel需要配合polyfill来使用。</p>
<p><code>Babel</code>更新比较频繁,网上搜出来的很多配置教程是旧版本的,可能并不适用最新的<code>Babel 7.x</code>,所以我们这里折腾一下最新的webpack4配置Babel方案:<a href="https://link.segmentfault.com/?enc=yOO0v2UwIObtRIH7YsIr4w%3D%3D.8qj07U0HQAhq8n6dctQuBMHll8M%2BgBpUmf%2BNyV4zR535WvJvlaBZqFqmzR8d7iVrwV%2Bhlc0s7Ny5fnxWwGgVrQ%3D%3D" rel="nofollow">babel-loader</a>。<br>1.安装<code>babel-loader</code>,<code>@babel/core</code>、<code>@babel/preset-env</code>。</p>
<pre><code>npm install -D babel-loader @babel/core @babel/preset-env core-js</code></pre>
<blockquote>
<code>core-js</code>是JavaScript模块化标准库,在<code>@babel/preset-env</code>按需打包时会使用<code>core-js</code>中的函数,因此这里也是要安装的,不然打包的时候会报错。</blockquote>
<p>2.修改webpack.config.js配置,添加rules</p>
<pre><code>const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'remember-scroll.js',
library: 'RememberScroll',
libraryTarget: 'umd',
libraryExport: 'default'
},
module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader'
}
}
]
}
};</code></pre>
<p>表示.js的代码使用<code>babel-loader</code>打包。</p>
<p>3.在根目录新建<code>babel.config.js</code>,参考<a href="https://link.segmentfault.com/?enc=9yP94P0YS30g%2Fnc1OMBMBg%3D%3D.LhbF3sMsNoER05o8KAMFPNLPRwFsZS6ZSEPA9dcYmXqxGWmZnWi4OqWrAqOzVxCi" rel="nofollow">Babel官网</a></p>
<pre><code>const presets = [
[
"@babel/env",
{
targets: {
browsers: [
"last 1 version",
"> 1%",
"maintained node versions",
"not dead"
]
},
useBuiltIns: "usage",
},
],
];</code></pre>
<p><code>browsers</code>配置的是目标浏览器,即我们想要兼容到哪些浏览器,比如我们想兼容到IE10,就可以写上IE10,然后webpack会在打包时自动为我们的库添加polyfill兼容到IE10。</p>
<p>博主这里用的是推荐的参数,来自:<a href="https://link.segmentfault.com/?enc=MYkJc0sO%2FqyPBb7XpWqfxQ%3D%3D.jxjNWRl0oZSxuRZSJjbd2EJBOImsQ8J0jKxkmDlHc96kud5iRhnHw9pem4Em2Zlw" rel="nofollow">npm browserslist</a>,这样就能兼容到大多数浏览器啦。</p>
<p>配置好后,<code>npm run dev</code>打包即可。<br>此时,我们已经实现了第三个小目标:兼容低版本浏览器。</p>
<h2>生产环境打包</h2>
<p><code>npm run dev</code>打包出来的js会比较大,一般还需要压缩一下,而我们可以使用webpack的production模式,就会自动为我们压缩js,输出一个生产环境可用的包。在<code>package.json</code>再添加一条<code>build</code>命令:</p>
<pre><code> "scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --mode=production -o dist/remember-scroll.min.js --colors",
"dev": "webpack --mode=development --colors"
},</code></pre>
<p>这里同时指定了输出的文件名为:<code>remember-scroll.min.js</code>,一般生产环境就是使用这个文件啦。</p>
<h2>发布到npm</h2>
<p>经过上面的步骤,我们已经写完这个库,有需求的同学可以将库发布到npm,让更多的人可以方便用到你这个库。</p>
<p>在发布到npm前,需要修改一下<code>package.json</code>,完善下描述作者之类的信息,最重要的是要添加<code>main</code>入口文件:</p>
<pre><code>{
"main": "dist/remember-scroll.min.js",
}</code></pre>
<p>这样别人使用你的库时,可以直接通过<code>import RememberScroll from 'remember-scroll'</code>来使用<code>remember-scroll.min.js</code>。</p>
<p>发布步骤:</p>
<ol>
<li>先到<a href="https://link.segmentfault.com/?enc=FVYZc5QuS8n91qjXb%2FrH5w%3D%3D.uM%2BeYV5T88h72GWDb8K0cOgkKu2TrD8h5YdAkGl5WqQ%3D" rel="nofollow">https://www.npmjs.com/</a>注册一个账号,然后验证邮箱。</li>
<li>然后在命令行中输入:<code>npm adduser</code>,输入账号密码邮箱登录。</li>
<li>运行<code>npm publish</code>上传包,几分钟后就可以在npm搜到你的包了。</li>
</ol>
<p>至此,基本就完成一个插件的开发发布过程啦。</p>
<p>不过一个优秀的开源项目,还应该要有详细的说明文档,使用示例等等,大家可以参考下博主这个项目的<a href="https://link.segmentfault.com/?enc=4CEdLS1lqy3j61EK8QZZMA%3D%3D.cCp%2BuCz9AsgzmMQ16vEgm9r3I2Le4ri7w9sUmhu5B6cNKwMP1n9lxo3nicXrWgQT58L3%2BiJmkly2DWnDKOG6qWZzEq6mlst684TzcKiSO%2F4%3D" rel="nofollow">README.md</a>, <a href="https://link.segmentfault.com/?enc=o%2FdE3B8FoAQSZego%2FvRsBQ%3D%3D.hElRebTPomiJOa%2FAsbH0ikqIBlDoKRUUXjCVhfEwXBA7SADDjANqtqq2buSUJ2IArPND6pRRJllB5K1RiXJc9D7ZFXrniKdjnt0dWLjYw9s%3D" rel="nofollow">中文README.md</a>。</p>
<h2>最后</h2>
<p>文章写了好几天了,可谓呕心沥血,虽然比较啰嗦,但应该比较清楚地交代了<strong>如何运用ES6语法从零写一个记住用户离开位置的js插件</strong>,也很详细地讲解了如何用最新的<code>webpack</code>打包我们的库,希望能让大家都有所收获,也希望大家能到<a href="https://link.segmentfault.com/?enc=s7izZBzwUkfiAJIdvda8hQ%3D%3D.ao2zI2%2B9NMK9hobPOyduGuP2bZ3ToFv41R8CqLNw5CJ7mnjS0J4hFW5BDe0no%2FFS" rel="nofollow">GitHub</a>上点个Star鼓励一下啦。</p>
<p><a href="https://link.segmentfault.com/?enc=TEOFvIh%2F1C2typdMNy6Swg%3D%3D.6vItbH6q3DEGtcl53AgkbOiqReTyvmhl6SxDJkKQv8v0c3i25sKCOB1ofD3r6xnJ" rel="nofollow">remember-scroll</a>这个插件其实几个月前就已经发布到npm了,一直比较忙(懒)没写章分享。虽然功能简单但很有诚意,能兼容到IE9。</p>
<p>使用起来也非常方便简单,可直接通过<code>script</code>标签<code>cdn</code>引入,也可以在vue中<code>import RememberScroll from 'remember-scroll'</code>使用。文档中有详细的使用示例:</p>
<ul>
<li><a href="https://link.segmentfault.com/?enc=PJeZoi5qpyGYXzU0ryf4uA%3D%3D.nERBHkbUKQA0G%2FfC0NfLfNGo59D7F%2B6nj8vSzWLT6dJIpSD9cmc2FBpHzOELjJDbWJGblR%2B0rXXn%2F7LWmmIjuDSMcsiE0ygflOMb1HwqF94%3D" rel="nofollow">script标签使用方式</a></li>
<li><a href="https://link.segmentfault.com/?enc=SRcIMfZbCY5EBLyBV3Us8g%3D%3D.n36WrmwK18Zmf6mUVP3dvj7r69M4MU1s6pWZUG0RBZ47WP5QhOr44269qRGvMZ8ygH%2B1QoKQ1IwuhAU6gXhvqxYdHesy%2BO4iazAC1YrqzAk%3D" rel="nofollow">vue中使用方式</a></li>
<li><a href="https://link.segmentfault.com/?enc=SInv%2FySodxjzTXLzS0aNSw%3D%3D.VlE3e08eJjvnKhZkafVMZHFN%2FnPb6KjOBSNWaoNquBFNUlnRGooNwlj6DSpKZ%2B%2FKkT7UWfrznH0j6vw%2BDzv0iuo0RDr2rlBqwgrt%2BdNEP5k%3D" rel="nofollow">vue异步获取数据时使用方式</a></li>
</ul>
<p>项目地址<a href="https://link.segmentfault.com/?enc=7y8jsd6OlhJ1nR%2Fr0GFTUw%3D%3D.E8OYIPy1xfNXXmg4jQRVQ%2Fso61LsQo9upqFklCJKKtPM3yLEiCMEoTxxyQRU625X" rel="nofollow">Github</a>,在线<a href="https://link.segmentfault.com/?enc=F05%2FhoVUzQfjKNBhNwDEzw%3D%3D.SnsGcb6EyJGgaNw10Zrj%2BdkyvX3TJfM6CHLCuHI1I%2BzBjwURDJjR%2FV1BXgbdAK9tBwPbFbOP6SK%2FWPepFIy%2FZQ%3D%3D" rel="nofollow">Demo</a>。</p>
<p>欢迎大家评论交流,也欢迎PR,同时希望大家能点个Star鼓励一下啦。</p>
mpvue开发音频类小程序踩坑和建议
https://segmentfault.com/a/1190000018470280
2019-03-12T10:18:29+08:00
2019-03-12T10:18:29+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
1
<h2>前言</h2>
<p>这是我第一次开发小程序,开发的产品是音频类的,在大佬的建议下采用了<code>mpvue</code>,一周时间把功能都做出来,由于不太熟悉mpvue和微信小程序,足足用了一周时间来改bug才出来一个能用的版本,在这里整理分享下我开发时遇到的一些问题和给出一些建议。<br><img src="/img/remote/1460000018470283" alt="" title=""></p>
<h2>在<code>Linux</code>上开发小程序</h2>
<p>在公司电脑装了双系统,日常用的是<code>Ubuntu</code>系统,Linux或Mac的开发环境对前端相对来说会友好一些。微信小程序官方的开发者工具只有<code>Windows</code>和<code>Mac</code>版本,所以这就尴尬了。</p>
<p>不过还好,发现已经有大神在GitHub上做了Linux的支持,推荐给大家:<a href="https://link.segmentfault.com/?enc=Vq%2F2gE2ttyiMTX%2FuWJ3OIw%3D%3D.1CP12Az1mRA8YtPslINQrFmYV8GIeI1%2FcPu9OeXZ2kSJsG%2BPY1m7jMqxqIrStBcG" rel="nofollow">Linux微信web开发者工具</a>。<br>根据教程安装使用即可,使用时就用<code>./bin/wxdt</code>命令打开。不过用了几天后面觉得不太方便,就索性切回Windows系统用官方最新的版本了。</p>
<h2>封装wx.request为Promise</h2>
<p><code>wx.request</code>用于发起http请求,但平时习惯了Promise的写法,所以还是封装一下这个方法为Promise的形式。<br>我看很多小程序会使用<a href="https://link.segmentfault.com/?enc=%2Bi6uEPhZqufcx3ClIOjIkQ%3D%3D.LxUsU2iBmzOt%2Bl0aOtt%2BdpgQUG%2Fs0lqeDZX%2F9rNsxjw%3D" rel="nofollow">fly</a>这个库。</p>
<p>但个人觉得发起请求不需要那么强大的功能,小程序本身就应该是一个轻量级的东西,引入一个库可能会导致项目打包变大,可能让小程序更卡,所以本着能自己写就自己写吧的心态,索性自己封装一下算了。</p>
<p>在<code>src/utils</code>,新建一个<code>request.js</code>:</p>
<pre><code>const apiUrl = 'https://your server.com/api/'
const request = (apiName, reqData, isShowLoading = true) => {
// 某些请求可能不需要显示loading
if (isShowLoading) {
wx.showLoading({
title: '正在努力加载中',
mask: true
})
}
return new Promise(function (resolve, reject) {
wx.request({
url: apiUrl + apiName,
method: 'POST',
data: reqData,
header: {
'content-type': 'application/json' // 默认值
},
success (res) {
if (res.data.code === 0) {
// 与后端约定code=0时才是正常的
resolve(res)
} else {
reject(res)
}
},
fail (err) {
reject(err)
},
complete (res) {
wx.hideLoading()
}
})
})
}
export default request</code></pre>
<p>当然这是个简化版的,我实际项目中还会在初始化时加入一些<code>token</code>之类的参数,大家能看明白是这样封装成Promise的就可以啦。</p>
<h2>使用vant-weapp</h2>
<p>小程序已经支持了npm安装,但不太会弄。还是按网上方法,将项目clone下来放进static目录下。</p>
<pre><code>git clone https://github.com/youzan/vant-weapp.git</code></pre>
<p>然后将<code>vant-weapp</code>的<code>dist</code>目录拷贝到项目的static目录下(尽可能精简,删掉一些奇奇怪怪的如<code>.github</code>的东西,所以直接使用dist目录),改名为<code>vant</code>(也可以不改名)。全局使用时,可以在<code>app.json</code>引入:</p>
<pre><code> "usingComponents": {
"van-button": "/static/vant/button/index",
"van-field": "/static/vant/field/index"
},</code></pre>
<blockquote>注意:需要打开微信开发者工具中的ES6转ES5功能</blockquote>
<p>一开始以为使用起来和web端的没啥差别,但没想到那么麻烦。比如:在vue中是可以使用<code>v-model</code>的,但在mpvue中的小程序中不能使用,只能</p>
<pre><code><van-field :value="password" type="password" @change="pwdChange" input-class="myClass" /></code></pre>
<p>而且不能随意灵活添加class修改组件的样式,需要vant组件支持提供外部样式才可修改,比如上面的<code>van-field</code>是通过<code>input-class</code>来添加样式控制的,很不方便。而且某些内部样式由于没有外部样式表,根本改不了。</p>
<p>综上: 在微信小程序使用第三方组件库不太方便,样式修改比较麻烦,如果产品是有UI设计时,<strong>尽量不使用</strong>,有时候自己实现样式可能更快,而且项目体积更小。</p>
<h2>使用vuex</h2>
<p>mpvue官方的快速模板中是将vuex放在<code>counter</code> 这个page目录下,可能习惯了vue官方写法的很多同学(包括我)不太喜欢,所以最好就改为vuex官方的写法。</p>
<p>在src目录下建一个<code>store</code>的文件夹,分别建以下文件:</p>
<p><img src="/img/remote/1460000018470284?w=286&h=159" alt="" title=""><br>项目不太复杂时不建议使用modules,使用起来比较麻烦。</p>
<p>贴一下<code>index.js</code>的代码,其他的<code>actions.js</code>,<code>getters.js</code>按官方的写法就好啦。</p>
<pre><code>import Vue from 'vue'
import Vuex from 'vuex'
import * as actions from './actions'
import * as getters from './getters'
import state from './state'
import mutations from './mutations'
import createLogger from 'vuex/dist/logger'
Vue.use(Vuex)
const debug = process.env.NODE_ENV !== 'production'
export default new Vuex.Store({
actions,
getters,
state,
mutations,
strict: debug,
plugins: debug ? [createLogger()] : []
})
</code></pre>
<p><code>vuex/dist/logger</code>是vuex在开发环境可以自动打印日志的工具,debug比较方便,建议使用。<br>然后在<code>src/main.js</code>引入:</p>
<pre><code>import Vue from 'vue'
import App from './App'
import store from '@/store'
Vue.config.productionTip = false
App.mpType = 'app'
Vue.prototype.$store = store
const app = new Vue({
store
})
app.$mount()</code></pre>
<p>这样就可以在项目中正常使用啦,完全支持<code>mapState</code>,<code>mapActions</code>,<code>mapGetters</code>的写法,比如在<code>pages/index/index.vue</code>中使用:</p>
<pre><code><script>
import { mapState, mapActions } from 'vuex'
export default {
computed: {
...mapState(['myAudio'])
},
methods: {
...mapActions(['myActions'])
},
created () {
this.myActions() //调用vuex中的方法
}
}
</script></code></pre>
<h2>踩坑指南</h2>
<p>其实大多数坑可能是mpvue的,很多情况也是自己不熟悉小程序生命周期导致的一些奇奇怪怪的bug。</p>
<h3>mpvue是支持小程序原生组件的</h3>
<p>mpvue会将<code>div</code>编译为小程序中的<code>view</code>。一开始我不了解,以为用了mpvue后就不能使用小程序原生支持的组件了,比如<code>swiper</code>,<code>scroll-view</code>等,小程序是支持的,可以放心使用哈哈。</p>
<h3>npm run build后样式丢失</h3>
<p>本来在开发环境正常的,然后准备发版<code>npm run build</code>后发现样式丢失了。然后重新<code>npm start</code>排查问题,样式还是丢失的。内心此时是mmp的:npm run build丢失就算了,我没改什么东西重新npm start后为什么还是丢失,之前还是正常的呀?</p>
<p>刚开始怀疑是缓存什么的问题,删掉的dist目录,重启开发者工具,甚至重启电脑都试了一下,这是我遇到的超级诡异的问题之一。</p>
<p>冷静下来想到:之前的版本是正常的,一定是新版本引入了什么导致了打包样式的丢失。于是回滚版本一个个build排查问题,最后找到了原因:<strong>在一个page中引入了其他page,即在页面中import另一个页面。</strong></p>
<p>在我这里的具体例子是:我在<code>pages/index/index.vue</code> 中想做底部共用一个tabbar,页面根据tabbar的值来显示对应的子级页面:<code>pages/page1/index.vue</code>和<code>pages/page2/index.vue</code>。</p>
<p>所以我将这两个页面当做子组件来引入了:<code>import Page1 from '@/pages/page1'</code>,一开始没有问题,等重启项目,或者build后就发现样式丢失了。</p>
<p>这可能是mpvue打包机制的一个限制,即<code>页面不能将另一个页面当子组件来引用</code>,否则会导致样式丢失。</p>
<h3>背景音频的src无法读取</h3>
<p>项目中希望用户退出小程序后依然能播放音频,所以用到了背景音频的api: wx.getBackgroundAudioManager()。</p>
<pre><code>this.audio = wx.getBackgroundAudioManager()
this.audio.src = 'http://ws.stream.qqmusic.qq.com/M500001VfvsJ21xFqb.mp3?guid=ffffffff82def4af4b12b3cd9337d5e7&uin=346897220&vkey=6292F51E1E384E061FF02C31F716658E5C81F5594D561F2E88B854E81CAAB7806D5E4F103E55D33C16F3FAC506D1AB172DE8600B37E43FAD&fromtag=46'
this.audio.title = '此时此刻' //注意必填
this.audio.epname = '此时此刻'
this.audio.singer = '许巍'
this.audio.coverImgUrl = 'http://y.gtimg.cn/music/photo_new/T002R300x300M000003rsKF44GyaSk.jpg?max_age=2592000'</code></pre>
<p><code>title</code>和<code>src</code>赋值后会直接播放音频,后面的几个属性建议也填上,因为播放背景音频时微信是有个界面需要封面图和歌手名称等的。</p>
<p>如果想要获取当前正在播放的音频src,本来以为通过<code>this.audio.src</code>来获取就可以了但是有bug。</p>
<p>在开发者工具中是可以正常获取的,即开发时是没问题的,但在真机上返回的是<code>undefined</code>,因此不能用<code>this.audio.src</code>来获取当前播放的音频url,得用一个变量来存这个数据。</p>
<h3>直接使用音频的currentTime可能渲染不及时</h3>
<p>currentTime用于显示当前的播放进度,但我用在子组件中时经常更新不及时,打印是正常的,但试图渲染不及时,有时候需要点击一下才能重新渲染,这可能是mpvue使用时才会遇到。</p>
<p>所以建议还是项目自身维护一套背景音频的变量比较好一点,比如放在<code>vuex</code>中。监听<code>BackgroundAudioManager.onTimeUpdate()</code>方法每次赋值到自身维护的变量中。</p>
<h3>音频的onCanplay方法不一定每个音频都会触发</h3>
<p>一开始我监听在<code>onCanplay</code>方法,将音频的时长信息<code>duration</code>赋值到vuex中存起来,但发现<code>onCanplay</code>有时候是不会触发的,比如重新赋值src播放下一首时,很尴尬。</p>
<p>所以不要太依赖onCanplay这个方法,还好目前直接使用<code>audio.duration</code>好像不会出现像上面的<code>currentTime</code>渲染不及时的问题,所以就这样用着先。</p>
<h3>音频播放结束,即onStop后,不能再通过audio.play()的方法重新播放,得重新赋值src</h3>
<p>正常来说,音频播放结束后,音频的src是不变的,再次<code>play()</code>应该是可以的。但在小程序中偏偏不行,得重新赋值src才能重新播放,这应该是小程序的一个bug。。。</p>
<p>所以需要判断一下<strong>暂停</strong>和<strong>停止</strong>的情况,用不同的办法播放。正常来说,音频暂停时<code>currentTime</code>是不为0的,而结束时<code>currentTime</code>会为0。</p>
<p>所以可以通过<code>currentTime</code>(最好是自己维护的变量)来判断暂停和停止的情况:<strong>如果currentTime不为0,表示是暂停的情况,可以用<code>play()</code>,如果小于等于0,则重新赋值src播放</strong>:</p>
<pre><code>if (currentTime) {
this.audio.play()
} else {
this.audio.src = 'xx.mp3'
}
</code></pre>
<h3>mpvue不支持直接在template上直接绑定函数</h3>
<p>这个是mpvue文档上有写的,不过一开始并不是很理解,也踩坑了,所以在这里提一下,避免不知道的同学踩坑找半天。</p>
<pre><code><template>
<div v-for="(item, index) in list" :key="index">{{ formatItem(item) }}</div>
</template>
<script>
export default {
data () {
return{
list: [1, 2, 3]
}
},
methods: {
formatItem (item) {
return `我是${item}`
}
}
}
</script></code></pre>
<p>上面的代码应该是日常vue中比较常用的,就是将数据传参给方法做一些处理,这个在mpvue中是不支持的,会被编译成一个空字符串。</p>
<h3>小程序中可放心使用css3的一些特性</h3>
<p>比如高斯模糊</p>
<pre><code>filter: blur(50px);</code></pre>
<h3>如果要使用动画,尽量用<code>css</code>动画代替<code>wx.createAnimation</code>
</h3>
<p>在实际使用时,<code>wx.createAnimation</code>做动画其实很卡,性能很差,所以在需要使用动画时,建议尽量使用css做动画。</p>
<p>在小程序中是支持css动画的,<code>transition</code>,<code>animation</code>,<code>@keyframes</code>这些特性都支持。</p>
<p>比如做一个div一直旋转的动画,大家可以对比一下两个版本:</p>
<ul><li>
<code>wx.createAnimation</code>版本</li></ul>
<p>原理:通过setInterval()不断更新div的旋转位置</p>
<pre><code><template>
<div class="cover" :animation="animationData"></div>
</template>
<script>
export default {
data () {
return {
animationData: '',
animation: '',
rotateCount: 0,
timer: ''
}
},
components: {
},
methods: {
startRotate () {
this.timer = setInterval(() => {
this.rotateAni(++this.rotateCount)
}, 100)
},
rotateAni (n) {
if (!this.animation) {
return
}
// 每100毫秒旋转10度
this.animation.rotate(10 * n).step()
this.animationData = this.animation.export()
}
},
onShow () {
// 页面从隐藏到显示时才执行
if (!this.animation) {
this.animation = wx.createAnimation()
this.startRotate()
}
},
onReady () {
// 第一次初始化时会执行
if (!this.animation) {
this.animation = wx.createAnimation()
this.starRotate()
}
},
onHide () {
// 页面隐藏时会执行,避免频繁的setData操作,将定时器停掉
this.timer && clearInterval(this.timer)
},
beforeDestroy () {
// 页面卸载,也停掉定时器
this.timer && clearInterval(this.timer)
}
}
</script>
<style scoped lang="scss">
.cover {
left: 20px;
bottom: 70px;
border-radius: 50%;
background: #fff;
position: absolute;
width: 50px;
height: 50px;
background: rgba(0, 0, 0, 0.2);
box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
z-index: 10000;
}
</style></code></pre>
<ul><li>使用css的<code>@keyframes</code>做旋转动画</li></ul>
<pre><code><template>
<div class="cover" :style="coverStyle"></div>
</template>
<script>
export default {
}
</script>
<style scoped lang="scss">
// 定义一个动画名为 rotate
@keyframes rotate {
0%,
100% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.cover {
left: 20px;
bottom: 70px;
border-radius: 50%;
background: #fff;
position: absolute;
width: 50px;
height: 50px;
background: rgba(0, 0, 0, 0.2);
box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
z-index: 10000;
// 使用动画
animation: rotate 4s linear infinite;
}
</style></code></pre>
<p>用js写的动画需要控制好setInterval的间隔时间和旋转角度,比较难调。而用css写动画很简单,性能比js好,代码量也很少。</p>
<h3>使用css动画时建议开启硬件加速</h3>
<p>为了动画更流畅,想尽办法做优化,虽然不知道有没效果,反正用了再说[手动滑稽]。</p>
<p>可以用<a href="https://link.segmentfault.com/?enc=%2FNGlqexlnfPP1g6OojZXoA%3D%3D.%2BnYbZcHsY%2F3fSpJM6L5S23mYhf7eG7RfVudPwjVyPjl2NNeY39ykDmFi0EpWwPwaJj03xS3vnDPjy5tAG0Votw%3D%3D" rel="nofollow">will-change</a>和<a href="https://link.segmentfault.com/?enc=74ExlKiBOZlum37popsptw%3D%3D.zcZvZ9WVrhSk8FzBrMTeDgUKYAD1ocUdxYi%2ByWaQ75vJJeT7dqxRsYMc3I1vdjaXoab70PghfbnUSDhByCe9cg%3D%3D" rel="nofollow">transform: translate3d(0,0,0)</a>开启硬件加速。我也不太会用,具体用法大家自行百度Google。</p>
<pre><code>will-change: auto;
transform: translate3d(0, 0, 0);</code></pre>
<h3>iPhoneX需要底部导航条预留34px(68rpx)的高度。</h3>
<p>由于小程序中不能设置<code>viewport-fit=cover</code>,所以也就没有web中的安全区域说法,目前主流的做法是通过<code>wx.getSystemInfoSync()</code>判断是否是ipx,若是则给页面底部撑高34px。</p>
<pre><code class="javascript">const res = wx.getSystemInfoSync()
if (res.model.indexOf('iPhone X') >= 0) {
this.isIpx = true
}</code></pre>
<p>注意是用<code>res.model.indexOf('iPhone X')</code>,在开发者工具的iPhone X中,model是全等于<code>iPhone X</code>的,但在真机中往往拿到的值是<code>iPhone X GZxxx</code>,即后面可能会带一串东西,所以用<code>indexOf</code>才是比较稳的,而且对<code>iPhone XR</code>等机型也适用。</p>
<p>由于还有其他安卓机的全面屏,不太可能一一判断,而且某些安卓全面屏是没有用iPhone底部的工具条的(不存在冲突的情况),所以我们只判断iPhone X的情况就可以了,其他全面屏就不需要给底部预留了。</p>
<p>至于全面屏布局的适配,需要用<code>flex</code>布局或者获取屏幕宽高来慢慢调了,建议最好用flex布局自适应处理。</p>
<h3>for循环中的子组件click事件无法触发</h3>
<p><code>Page -> 父组件 -> 子组件</code>,在子组件click后<code>$emit</code>一个事件出来,发现无法触发。</p>
<p>这个bug一开始没有出现,但偶然<code>npm run build</code>出现的,然后排查原因,后面即使回滚所有版本再npm start也还会出现。好像不触发则已,一发就不可收拾,这又是一个大坑,搜issue和加群问人,当晚下班回家研究到1点多都没有解决。</p>
<p>第二天继续研究,感觉可能是框架的原因,最后尝试升级一下mpvue版本,没想到就正常了。直接使用quick-strat项目的<code>mpvue</code>版本是 2.0.0,<code>mpvue</code>和<code>mpvue-template-compiler</code>升级到最新<code>2.0.6</code>就解决了。</p>
<p>事后查看mpvue版本记录,果然是框架本身原因,并且找到了<a href="https://link.segmentfault.com/?enc=bvuzTi7qsHpYM0NbIsFE5A%3D%3D.woIlFYhSLqBYnO%2F%2FZ2aklig3jlGMXjmdmUjQTrKx4DhLuFWlLxoLfqMqf5GNmXp7KAik%2B84%2BVyP6%2BnUlrPl9CA%3D%3D" rel="nofollow">issue</a>。</p>
<h3>npm run build后代码报错,再build一次可能报另一些错</h3>
<p>解决: 没找到原因,可能是引入vant导致的,打包时丢失了部分文件。多build几次,或者重启下小程序开发者工具就正常了。</p>
<h3>mpvue中created() 钩子会在页面初始化时全部一起触发,尽量不要用</h3>
<h3>小程序生命周期的理解</h3>
<ol>
<li>进入已销毁的page组件时依次触发: onLoad,onShow,onReady,beforeMount,mounted</li>
<li>第一次进入已销毁的子组件时依次触发: onLoad,onReady,beforeMount,mounted</li>
<li>第二次进入已销毁的子组件时依次触发: onLoad,onShow,onReady</li>
<li>再次进入 未被销毁的page组件、子组件时只触发: onShow</li>
</ol>
<p>mpvue文档中建议尽量不要使用小程序的生命周期,这个应该是为了让项目更好地适应支付宝小程序和头条小程序等,所以才这样建议大家尽量不要使用某一个小程序自身的api。</p>
<p>如果你们的小程序只是微信小程序(不考虑兼容其他平台小程序),我建议<strong>直接用小程序的生命周期</strong>,而不要用mpvue的生命周期,坑太多了。比如mpvue的created周期,初始化时所有的page都会执行,所以created这个周期是不能用了。</p>
<h3>onUnload不触发</h3>
<p>小程序中与平常web开发不同的是,它的页面会被缓存。举个例子:</p>
<ol>
<li>从<code>page1</code>跳转到<code>page2</code>,再从<code>page2</code>返回<code>page1</code>,此时的<code>page1</code>还没销毁,不会触发<code>onLoad</code>再重新渲染,而是直接使用之前的数据。从性能上来说,单纯的返回不应该再请求api获取数据重新渲染,这是对的,符合我们的预期。</li>
<li>而有时候,从<code>page2</code>返回<code>page1</code>时,我们希望<code>page1</code>是重新获取数据渲染的。比如在<code>page2</code>做了一个退出登录的操作,此时再返回<code>page1</code>时,还是会看到之前的数据。实际上我们的预期是:由于已经退出登录了,<code>page1</code>的数据应该被销毁了。</li>
</ol>
<p>在平常的web开发中,遇到上面的问题,我们可能是不管缓存,每次返回<code>page1</code>都再次请求api渲染最新的数据,牺牲掉部分性能从而保证逻辑的正确性。</p>
<p>在mpvue中我也尝试这样干了:想在<code>page1</code>的<code>onUnload()</code>生命周期中销毁数据,但是没有成功。即使在<code>page2</code>退出登录时,采用<code>wx.reLaunch()</code>重新刷一遍,<code>page1</code>的<code>onUnload()</code>生命周期也没有执行。所以<code>onUnload()</code>是有可能不执行的,建议慎用。</p>
<p>最后还是得想办法做到<strong>在<code>page2</code>控制<code>page1</code>的数据销毁或保留</strong>。想到这里,<code>vuex</code>就不自觉浮现在眼前了,如果page1的数据是通过vuex来控制的,那么我在page2就可以用vuex来灵活管理其他页面的数据了。</p>
<p>如果page2做退出登录操作时,就让page1的数据销毁,如果是不退出登录正常返回,page1的数据还是正常,做到灵活控制。</p>
<p>个人平时web开发很少用<code>vuex</code>,因为项目比较简单不用那么复杂的全局数据传递。但在小程序中,建议全局使用<code>vuex</code>来控制所有数据(当然是得根据需求来用)。</p>
<h2>总结</h2>
<p>第一次开发小程序就直接上了mpvue,可能有些坑已经很多同学总结过了,有些坑可能是不熟悉而导致的,但自己没有去踩过一遍可能不够深刻。</p>
<p>有两种坑会比较难啃:</p>
<ol>
<li>框架本身的问题,如mpvue2.0.0出现的子组件无法触发事件的问题。</li>
<li>开发者工具和真机运行环境不一致导致的坑。</li>
</ol>
<p>遇到真机和开发者工具不一致的情况,可按以下步骤排查:</p>
<ol>
<li>有可能是缓存,可以杀掉之前的版本再跑起来</li>
<li>手机微信版本太低,可能api不支持,用<code>wx.canIUse</code>打印一下</li>
<li>手机端某些属性不支持读取,比如上面的<code>this.audio.src</code>,可以在真机打印调试一下</li>
<li>代码在手机端运行有报错,可以在手机端开启调试,看一下log</li>
<li>微信设计上的坑,百度下是否有相关的案例和解决办法</li>
</ol>
<p>而遇到mpvue框架的问题可以:</p>
<ol>
<li>去搜一下<code>mpvue</code>的issue看有没相关解决办法</li>
<li>尽量使用最新版本的框架,可能某些问题已经修复了的。实在解决不了的,建议想办法绕过,换一种方法来实现。</li>
</ol>
<p>希望对大家有所帮助。</p>
使用acme.sh撸一个免费且自动更新的HTTPS证书
https://segmentfault.com/a/1190000018244767
2019-02-22T10:03:28+08:00
2019-02-22T10:03:28+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
3
<h2>前言</h2>
<p>一直想撸一下https,最近刚好有点空,就实现了一下。之前看过一篇<a href="https://link.segmentfault.com/?enc=1gJr4ghFPhQ3ZCbDVvfWJw%3D%3D.N5Dnme5Vd0sMqapsxaWV%2B8StapUkthOSOC5N1q050FGziN3IGTTL6mplGFgldj%2Bp" rel="nofollow">教你快速撸一个免费HTTPS证书</a>的文章,通过<code>Certbot</code>来管理<code>Let's Encrypt</code>的证书,使用前需要安装一堆库,觉得不太友好。所谓条条大路通罗马,肯定还有其他方法可以做这个事情。</p>
<p>经过一番研究,发现了 <a href="https://link.segmentfault.com/?enc=rdWC7pRf%2Fcuss3sYAsO%2BSA%3D%3D.f32nLNwznopu8GlCOg6l37h6rWrqt7T4%2FBL0Q6PHvgccIEPlVrwYre1U6BnIt3VR" rel="nofollow">acme.sh</a> 这个库,这个是用Shell脚本编写的,不需要安装其他东西,比较纯净,觉得比较适合自己,记录一下过程。</p>
<h2>准备工作</h2>
<ol>
<li>一个已解析好的域名(可以用http来访问)。</li>
<li>开启服务器的443端口防火墙。</li>
</ol>
<h2>步骤</h2>
<h3>一、安装acme.sh</h3>
<pre><code>curl https://get.acme.sh | sh</code></pre>
<p>这个命令后会将acme.sh安装到<code>~/.acme.sh/</code>目录下<br>重新载入<code>~/.bashrc</code></p>
<pre><code>source ~/.bashrc </code></pre>
<h3>二、生成证书</h3>
<pre><code>acme.sh --issue -d www.your-domin.com --webroot /srv/your-domin.com/</code></pre>
<p>这个命令的意思是用http方式将www.your-domin.com生成一个证书,<code>/srv/your-domin.com/</code>是你的网站根目录。(这个过程中<code>acme.sh</code> 会全自动的生成验证文件, 并放到网站的根目录, 然后自动完成验证. 最后又自动删除验证文件.)</p>
<h3>三、安装或copy证书到nginx目录</h3>
<p>默认生成的证书都放在安装目录下: ~/.acme.sh/,这个目录一般来说不能让nginx或Apache直接使用。所以我们需要将证书放到一个指定的目录,习惯是放在<code>/etc/nginx/ssl/</code>目录下。acme提供了--installcert来安装证书,只需指定目标位置, 然后证书文件会被copy到相应的位置。<br>先确保存在<code>/etc/nginx/ssl/</code>目录</p>
<pre><code>mkdir /etc/nginx/ssl</code></pre>
<p>copy证书并指定nginx reload命令</p>
<pre><code>acme.sh --installcert -d www.your-domin.com \
--key-file /etc/nginx/ssl/www.your-domin.com.key \
--fullchain-file /etc/nginx/ssl/fullchain.cer \
--reloadcmd "service nginx force-reload"</code></pre>
<p>service nginx force-reload是为了在让acme自动更新时候能够重启nginx使得证书生效。执行完命令可以在<code>/etc/nginx/ssl/</code>看到多了<code>www.your-domin.com.key</code>和<code>www.your-domin.com.cer</code>的文件。</p>
<h3>四、生成 dhparam.pem 文件</h3>
<pre><code>openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048</code></pre>
<p>这一步不是必须,但最好加上,后面配置好后会通过ssllabs.com 来验证一下,如果这一步ssl_dhparam 未配置,将导致 ssllabs.com 的评分降到 B。A+是最好。</p>
<h3>五、配置nginx</h3>
<p>证书已安装完毕,接下来就是让nginx来使用这个证书了。由于我这个服务器有几个站点,而目前只是一个站点配置了证书,因此只修改当前站点的conf即可</p>
<pre><code>server {
listen 80;
server_name www.your-domin.com;
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
ssl_certificate /etc/nginx/ssl/www.your-domin.com.cer;
ssl_certificate_key /etc/nginx/ssl/www.your-domin.com.key;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
...
}</code></pre>
<p><code>ssl_prefer_server_ciphers on;</code> 这个配置能提高证书的评分。<br><code>ssl_dhparam /etc/nginx/ssl/dhparam.pem;</code> 能提高证书评分,这个文件是在第四步时生成的,若没有做则不需要写这句。<br><code>nginx -t</code>验证一下nginx配置是否正确,然后<code>systemctl restart nginx</code>重启一下nginx,就可以用<a href="https://link.segmentfault.com/?enc=UJuruMk49GSKNXLGG7xIJA%3D%3D.Yvu3q4MSQinlOaBlxFqjuPmxRhM%2Fupbc2Nxwqmt%2Bm8SWuZU7y%2F3LPQmg4hPc3H8gwlB8wES%2FlQYLYG9VgxA1fovP5f4W0AAgmL0PMGED1pWog13rX2m%2FBahUJ2Esf7b5R5yLAnKz2xWiOIi%2B1hJCgA%3D%3D" rel="nofollow">https://www.your-domin.com测...</a>。</p>
<h3>六、证书更新</h3>
<p>Let's Encrypt 的证书有效期是 90 天的,需要定期重新申请,不过acme在安装的时候就已经设置了自动更新,所以这一步不用关心,很省心。<br>这里了解一下acme.sh的自动更新:安装acme时会自动为你创建 cronjob, 每天 0:00 点自动检测所有的证书, 如果快过期了, 需要更新, 则会自动更新证书.<br>查看任务</p>
<pre><code># crontab -l
47 0 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null</code></pre>
<p>手动renew一下证书可以通过这个命令</p>
<pre><code>acme.sh --cron -f</code></pre>
<h3>七、设置软件自动更新</h3>
<p>目前由于 acme 协议和 letsencrypt CA 都在频繁的更新, 因此 acme.sh 也经常更新以保持同步.所以为了省心省力,最好还是设置一下软件的自动更新,执行下面的命令就可以了。</p>
<pre><code>acme.sh --upgrade --auto-upgrade</code></pre>
<h2>其他</h2>
<p>在这个网站可以验证一下你的证书级别,根据我上面的配置可以评级为A。<br><a href="https://link.segmentfault.com/?enc=GRvOIhz%2BXEL5y%2BV5CXM3bQ%3D%3D.yHmNsYIwYYuoRgUN0lRrDCK0zvHl5Cwk6nAA1TpEOxSwzUIs6tmQHBtvdLyoYLYRr69cZYpC6OfhmgEhEiQQzfb2mUVD8e5uW8tGqbDnD5c%3D" rel="nofollow">https://www.ssllabs.com/sslte...</a></p>
<h2>参考文章</h2>
<ul>
<li><a href="https://link.segmentfault.com/?enc=CtCsfrZauO4JmUw8TPujuQ%3D%3D.tAxJFNijTRfMVN9oYPMFQkPgyOYjnBEs6LGAkCGnqzywyX%2BxQury%2FF5eE9yha4hT2XR%2FApZcVsREnZBNsALAnw%3D%3D" rel="nofollow">acme.sh说明</a></li>
<li><a href="https://link.segmentfault.com/?enc=4IMcM%2BBJZXZIK5%2B4LCazPg%3D%3D.w%2Bkcz1nfx321T%2FAhfqileSfdoWW5cIlhYMjjpG%2BdRca%2B01lF5ufRiY6%2B0K0gR3tJ" rel="nofollow">使用 acme.sh 给 Nginx 安装 Let’ s Encrypt 提供的免费 SSL 证书</a></li>
<li><a href="https://link.segmentfault.com/?enc=EwjkIXQsz9DKYAsp0yvSrA%3D%3D.oW2tADpAux2mvErk4rBc43Ju4xL8%2BO3LvSACth7uqJsGpR2mrtNSp5T5yeZg2tyIJPSSRl%2BYQrqqw7iBeyByAw%3D%3D" rel="nofollow">让你的网站免费开启Https访问,绿色健康小清新</a></li>
</ul>
WebView与APP交互实战记录
https://segmentfault.com/a/1190000018208609
2019-02-19T19:21:47+08:00
2019-02-19T19:21:47+08:00
fengxianqi
https://segmentfault.com/u/fengxianqi
6
<h2>WebView与APP交互</h2>
<p>WebView与APP交互,即网页通过<code>JSBrige</code>调用APP的功能,APP也可以通过<code>JSBrige</code>调用网页提供的方法。最近刚好接触到这一块,记录一下前端侧的实际操作过程,这篇文章适合还没接触过这一块的同学们,这里不讲原理,直接开始实战的过程。</p>
<h2>准备工作</h2>
<p>与客户端同学沟通好使用的JSBrige库,我这里使用的是下面这两个库:</p>
<p>iOS(1.1w+ Star): <a href="https://link.segmentfault.com/?enc=E%2Bq7fD2oTd6O7JJd4QF80Q%3D%3D.o3ld%2BotCbTcDzo7ZlOXYi8QBDx0m9eGTELc%2BgascINkKr8ewSqjSR%2Fy8lU1ZhjqMewHPA2zCFbFiU6L6Mz3a0g%3D%3D" rel="nofollow">https://github.com/marcuswestin/WebViewJavascriptBridge</a></p>
<p>Android(6k+ Star): <a href="https://link.segmentfault.com/?enc=RiDBPOuxMf1pnUpdII75vw%3D%3D.neBcBH7xcFJBuQIGSl0cDVaKJy12m4slPCeCNKImdd6qTMI33AI6%2BhqmiSmbrX4z" rel="nofollow">https://github.com/lzyzsd/JsBridge</a></p>
<p>Star数量比较高,使用的企业也比较多,所以还是比较可靠的,可以在它们的文档中示例代码。</p>
<blockquote>注意: github上有很多这样的库,但最好配套使用,即iOS和Android注入的jsBrige名称一致,我们前端使用时比较方便统一。</blockquote>
<h2>开发步骤</h2>
<h3>1. 统一封装APP注入的JSBrige</h3>
<p>ios和android注入的jsbrige可能会有些差异,所以在使用前我们需要抹平不同客户端的差异。<br>如果app的同学使用了上面说的库,安卓和iOS会在WebView中的window下注入一个<code>WebViewJavascriptBridge</code>的对象,iOS有可能是注入<code>WVJBCallbacks</code>的数组,也有可能需要通过<code>iframe</code>来注入。<a href="https://link.segmentfault.com/?enc=9rjPP5VgzkY9CbaB0zM%2B3g%3D%3D.5xRx3BE5brRHce6cJjXWQgXaqxirG94Z1Mx9oAnV%2B6iP9lhG%2FdeEh%2Bsddq6IUmfUWXzuv58nlQgvZ3bn7bc9qw%3D%3D" rel="nofollow">iOS的文档</a>中给出了一个兼容的示例代码:</p>
<pre><code class="javascript">function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}</code></pre>
<p>我们可以直接使用上面的代码,为了使用方便,在页面onload后,我们将这个函数挂在window下:</p>
<pre><code class="javascript">window.onload = function () {
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}
window.setupWebViewJavascriptBridge = setupWebViewJavascriptBridge
}</code></pre>
<p><code>window.setupWebViewJavascriptBridge</code>这个函数,就是我们所说的<code>JSBridge</code>,即WebView与APP交互的桥梁。</p>
<h3>2. JS调用APP的方法</h3>
<p>APP端的同学需要先用库提供的方法在APP端实现一个方法,方法名比如:<code>playMusic(musicId)</code>,可以传递参数<code>musicId</code>,表示让APP开始播放某一首音乐。js调用时如:</p>
<pre><code class="javascript">window.setupWebViewJavascriptBridge(function (bridge) {
bridge.callHandler('playMusic', { musicId: 1 }, function (data) {
console.log('app触发成功了,音乐正在播放。。。APP回调返回的数据:', data)
})
})</code></pre>
<p>上面相当于向app发起了一个<code>playMusic</code>请求,app收到请求后,执行相关的动作,然后可以<code>callback</code>,网页可以拿到回调并继续做相关动作(需要的话)。</p>
<h3>3. APP调JS提供的方法</h3>
<p>同样的道理,若想APP能调用JS提供的方法,JS中要先注册这个方法,方法名比如<code>stateChange(state)</code>,JS注册方法:</p>
<pre><code class="javascript">window.setupWebViewJavascriptBridge(function (bridge) {
bridge.registerHandler('stateChange', function (data, responseCallback) {
console.log('收到APP请求stateChange事件,请求的数据是:', data)
// 可以返回给app一个回调
responseCallback('朕已经收到APP爱卿的请求了,且退下!')
})
})</code></pre>
<p>到这里,WebView与app的交互其实就完成了,就是这么简单。</p>
<h2>注意事项</h2>
<h3>1. <code>window.onload</code>时就<code>callHandler</code>可能不成功</h3>
<p>一般在Android中会出现这个问题,这是因为APP注入的<code>WebViewJavascriptBridge</code>还不存在,JS就调用其中的方法了,所以就会没有效果。而Android的库也给出了这个<a href="https://link.segmentfault.com/?enc=b2SWrtA6skCpHj6WSN%2F%2FuA%3D%3D.8BxP13ZnF3CubdghuuMskgXxcAnE3EpeYK90sb25KxquMNhv7ROo6zT8lilBUyQb" rel="nofollow">注意事项</a>,所以得这样写:</p>
<pre><code class="javascript">window.onload = function () {
function registerAppEvent () {
window.setupWebViewJavascriptBridge(function (bridge) {
bridge.registerHandler('stateChange', function (data, responseCallback) {
console.log('收到APP请求,请求的数据是:', data)
// 可以返回给app一个回调
responseCallback('朕已经收到APP爱卿的请求了,且退下!')
})
})
}
// 兼容写法
if (window.WebViewJavascriptBridge) {
registerAppEvent()
} else {
document.addEventListener(
'WebViewJavascriptBridgeReady'
, function() {
registerAppEvent()
},
false
)
}
}</code></pre>
<p>但这里没有考虑到iOS的情况,在最新的iOS中<code>WebViewJavascriptBridge</code>可能是不存在的,所以上面写法<code>registerAppEvent()</code>在iOS可能无法执行。为了避免多次注入事件,我这里用了<code>setTimeout</code>,兼容两端的流程:</p>
<pre><code class="javascript"> let isInitBridgeEvent = false // 作为是否已注册过事件的标记
if (window.WebViewJavascriptBridge) {
registerAppEvent()
isInitBridgeEvent = true
} else {
document.addEventListener(
'WebViewJavascriptBridgeReady',
function () {
registerAppEvent()
isInitBridgeEvent = true
},
false
)
// 如果还没注册则再注册一次
setTimeout(() => {
if (!isInitBridgeEvent) {
registerAppEvent()
isInitBridgeEvent = true
}
}, 100)</code></pre>
<p>这坨代码写的很挫,让我尴尬癌都犯了,有没有热心的小伙伴帮我优化下写法[送花花]。</p>
<h3>2. js调用安卓后<code>callback</code>回调不成功,js收不到app返回的消息</h3>
<p>这个问题其实github上有<a href="https://link.segmentfault.com/?enc=58e8eJl8RGhXGET6uPE%2BYw%3D%3D.BR46B0Jum0ltvHlzDrZE%2FSxZuXahnXPqCVNHJddscb7dkuQmiZczd6DX2OaGixsT" rel="nofollow">issue</a>,这是安卓1.0.4版本没有解决的问题,最新的代码已经解决了。<br>解决办法是:安卓需要引入最新的master的代码,而不要使用1.0.4版本的代码。</p>