- 教你如何视频号关联小商店?
第一 点击 小商店 找到 图1 按钮 点击后 会提示图2按钮 这个时候你返回视频号 看看有么有关联成功! 如果这样还不会 联系我维号 我邀请你进小商店交流群 我发布教程视频 很简单的 2分钟完成! [图片] [图片] [图片]
2020-10-21 - 关于小程序接入客服消息的问题,今天亲测成功!
小程序的文档真的很多坑! 第一步,小程序登录后,配置消息推送的url,返回echostr,则接入成功!然后就可以把代码注释掉了,因为验证是GET你的url,而后面接入客服消息则是POST你的url。 第二步,小程序登录后,左边菜单栏有个【客服】,把自己的微信号添加进去当做客服。 前两步比较正常… 第三步,在url方法里做转发给微信内置的客服。 也是Post里面的返回。正常的话,返回‘success’就不会提示故障。 return “success”就可以了。 要接入客服,文档里这么说的,【消息转发到客服 如果公众号处于开发模式,普通微信用户向公众号发消息时,微信服务器会先将消息POST到开发者填写的url上,如果希望将消息转发到客服系统,则需要开发者在响应包中返回MsgType为transfer_customer_service的消息,微信服务器收到响应后会把当次发送的消息转发至客服系统。 <xml> <ToUserName><![CDATA[touser]]></ToUserName> <FromUserName><![CDATA[fromuser]]></FromUserName> <CreateTime>1399197672</CreateTime> <MsgType><![CDATA[transfer_customer_service]]></MsgType> </xml>】文档地址:https://developers.weixin.qq.com/doc/offiaccount/Customer_Service/Forwarding_of_messages_to_service_center.html 不过这会需要多看一眼参数说明,参数说明,参数说明!!!!! 微信服务器向你的url,Post过来的数据是这样的【<xml> <ToUserName><![CDATA[toUser]]></ToUserName> <FromUserName><![CDATA[fromUser]]></FromUserName> <CreateTime>1482048670</CreateTime> <MsgType><![CDATA[text]]></MsgType> <Content><![CDATA[this is a test]]></Content> <MsgId>1234567890123456</MsgId> </xml>】 同样需要多看一眼参数说明,参数说明,参数说明!!!!! 这里,你的return 里面需要怎么写?????很简单! 【return “<xml><ToUserName><![CDATA[” + FromUserName + “]]></ToUserName><FromUserName><![CDATA[” + ToUserName + “]]></FromUserName><CreateTime>” + CreateTime + “</CreateTime><MsgType><![CDATA[transfer_customer_service]]></MsgType></xml>”;】 把接收到的FromUserName,当做ToUserName返回。 把接收到的ToUserName,当做FromUserName返回。 CreateTime不变。 MsgType,返回<![CDATA[transfer_customer_service]]> 拼接好了,直接返回string类型即可! [图片]
2019-10-28 - wx.switchTab 跳转bug
[图片] 开发工具不报错 机型:ios客户端(测试机型 iphone 6/X) bug:1. 跳转2个页面,返回 tabBar页面,用switchTab报错,reLaunch不报错。 2. 如果tabBar都先点击一次,再跳转页面并返回,不报错
2019-08-14 - 腾讯云 微信小程序 即时通信IM demo
产品简介 即时通信(Instant Messaging,IM)基于QQ 底层 IM 能力开发,仅需植入 SDK 即可轻松集成聊天、会话、群组、资料管理能力,帮助您实现文字、图片、短语音、短视频等富媒体消息收发,全面满足通信需要。 应用场景 客服咨询 即时通信 IM 可满足商家与用户多场景沟通的需要,为客户提供专属客服服务,提升服务效率,通过与智能机器人结合,可有效降低人力成本,沉淀客户价值。 [图片] 直播弹幕 即时通信 IM 可支持弹幕、 送礼和点赞等多消息类型,轻松打造良好的直播聊天互动体验;提供弹幕内容审核能力,保证您的直播免受不雅信息干扰。 [图片] 网红带货 即时通信 IM 与商业直播相结合,通过提供点赞、询价、购物券等特定消息类型,帮助直播客户实现流量变现。 [图片] 教学白板 即时通信 IM 为可提供在线课堂,文本消息,画笔轨迹等能力,轻松实现教师学生沟通、画笔轨迹保存、大班课与小班课教学等教学场景。 [图片] 社交沟通 即时通信 IM 可实现单聊、群聊、弹幕等多种聊天模式,支持文字、图片、语音、短视频等多种消息类型,有效提升用户粘性与活跃度。 [图片] 企业办公 即时通信 IM 为企业客户提供覆盖桌面与移动端的完整解决方案,满足设备无缝切换的需求,提高企业内外沟通效率。 [图片] 智能设备 即时通信 IM 提供人与物、物与物协同通信,携手共进引领 5G 通信时代潮流。 [图片] 快速体验,IMSDK小程序demo运行 本 IM 小程序 demo 是基于 MpVue 框架进行开发的。[代码]一分钟跑通 demo[代码] 小节只是用于引导您打开编译后的文件进行快速预览,如果您想要进行二次开发,请看[代码]开发运行[代码]小节。 一分钟跑通demo 克隆仓库到本地 [代码]# 命令行执行 git clone https://github.com/tencentyun/TIMSDK.git # 进入小程序 Demo 项目 cd TIMSDK/WXMini [代码] 安装微信小程序 开发者工具。 使用微信开发者工具导入项目,请注意目录为 [代码]/dist/wx[代码],然后填入自己的小程序 AppID。 [图片] 配置 [代码]SDKAPPID[代码] 和 [代码]SECRETKEY[代码],获取方式参考:密钥获取方法 打开 [代码]/debug/GeneraterUserSig.js[代码] 文件 按图示填写相应配置后,保存文件 [图片] 本地配置如下图所示 勾选ES6转ES5选项 勾选不检验合法域名选项 基础库版本 > 2.1.1 [图片] 点击编译即可运行 [图片] 注意事项 合法域名 如果您要发布小程序,请将以下域名在【微信公众平台】>【开发】>【开发设置】>【服务器域名】中进行配置 进入微信公众平台,在小程序开发的服务器域名配置相关域名信息 添加到 request 合法域名: 域名 说明 是否必须 [代码]https://webim.tim.qq.com[代码] Web IM 业务域名 必须 [代码]https://yun.tim.qq.com[代码] Web IM 业务域名 必须 [代码]https://pingtas.qq.com[代码] Web IM 统计域名 必须 添加到 uploadFile 合法域名: 域名 说明 是否必须 [代码]https://cos.ap-shanghai.myqcloud.com[代码] 文件上传域名 必须 添加到 downloadFile 合法域名: 域名 说明 是否必须 [代码]https://cos.ap-shanghai.myqcloud.com[代码] 文件下载域名 必须 [图片] 开发运行 项目目录 [代码]├───sdk/ - 存放tim-wx.js,demo 中未使用,仅供自行集成 ├───build/ ├───config/ ├───dist/ │ └───wx/ - MpVue 项目编译后文件目录,使用小程序开发工具导入此文件夹 ├───src/ │ ├───components/ - 组件 │ ├───pages/ - 页面 │ ├───store/ - Vuex 目录 │ ├───stylus/ - 全局主题色样式,可以修改全局颜色 │ ├───utils/ - 方法 │ ├───app.json │ ├───App.vue │ └───main.js ├───static/ - 静态依赖资源 │ ├───debug/ - 包含 userSig 验证登录方法 │ ├───images/ - 图片 │ └───iview/ - 使用的 iview 组件 ├───_doc/ ├───.babelrc ├───.editorconfig ├───.eslintignore ├───.eslintrc.js ├───.postcssrc.js ├───index.html ├───package-lock.json ├───package.json ├───project.config.json └───README.md [代码] 准备工作 获取到您应用的 [代码]SDKAPPID[代码] 和 [代码]SECRETKEY[代码],方式参考:密钥获取方法 安装微信小程序 开发者工具 安装 nodejs 环境 ( Version > 8 ) ,选择合适您安装环境的安装包 安装后,在命令行输入[代码]node --version[代码] ,如果 > 8 即可 启动流程 克隆仓库到本地 [代码]# 命令行执行 git clone https://github.com/tencentyun/TIMSDK.git # 进入 Demo 项目 cd TIMSDK/WXMini [代码] 将[代码]project.config.json[代码]文件中的[代码]appid[代码]修改为自己微信小程序的[代码]appid[代码] [图片] 配置 [代码]SDKAPPID[代码] 和 [代码]SECRETKEY[代码],获取方式参考:密钥获取方法 打开 [代码]/static/debug/GeneraterUserSig.js[代码] 文件 按图示填写相应配置后,保存文件 [图片] 安装依赖并启动 [代码]# 安装demo构建和运行所需依赖 npm install # 构建并生成最终可在小程序开发工具内使用的代码 npm run start [代码] 使用 [代码]npm install[代码] 命令,如果有些依赖包无法成功安装 您可以试着切换源, 例如: [代码]npm config set registry http://r.cnpmjs.org/[代码] 然后再执行 [代码]npm install[代码] 使用微信开发者工具导入项目,目录为[代码]/dist/wx[代码] [图片] 本地配置如下图所示 勾选ES6转ES5选项 勾选不检验合法域名选项 基础库版本 > 2.1.1 [图片] 点击开发工具的编译即可预览该项目 [图片] 注意事项 合法域名 如果您要发布小程序,请将以下域名在【微信公众平台】>【开发】>【开发设置】>【服务器域名】中进行配置 进入微信公众平台,在小程序开发的服务器域名配置相关域名信息 添加到 request 合法域名: 域名 说明 是否必须 [代码]https://webim.tim.qq.com[代码] Web IM 业务域名 必须 [代码]https://yun.tim.qq.com[代码] Web IM 业务域名 必须 [代码]https://pingtas.qq.com[代码] Web IM 统计域名 必须 添加到 uploadFile 合法域名: 域名 说明 是否必须 [代码]https://cos.ap-shanghai.myqcloud.com[代码] 文件上传域名 必须 添加到 downloadFile 合法域名: 域名 说明 是否必须 [代码]https://cos.ap-shanghai.myqcloud.com[代码] 文件下载域名 必须 [图片] 项目截图 [图片] 备注 页面结构 目录 /src/pages 页面 简介 login/ 登录页 index/ 首页,对话列表 chat/ 聊天对话页 & 群信息/用户信息 contact/ 通讯录 own/ 个人信息 create/ 创建群聊 members/ 群成员 profile/ 修改个人信息 groups/ 群列表 groupDetail/ 群详细页 system/ 系统通知页 blacklist/ 黑名单页 detail/ 个人信息&群信息 friend/ 发起会话 mention/ @选择页 注意事项 1. 避免在前端进行签名计算 本 Demo 为了用户体验的便利,将 [代码]userSig[代码] 签发放到前端执行。若直接部署上线,会面临 [代码]SECRETKEY[代码] 泄露的风险。正确的 [代码]userSig[代码] 签发方式是将 [代码]userSig[代码] 的计算代码集成到您的服务端,并提供相应接口。在需要 [代码]userSig[代码] 时,发起请求获取动态 [代码]userSig[代码]。更多详情请参见 服务端生成 UserSig。 2. 如果无法访问github或者访问速度过慢 下载zip包 解压后,进入 TIMSDK/WXMini目录,即可查看demo代码。
2019-09-16 - TextArea判断输入emoji
[图片] emoji表情,怎样过滤,或者说怎样判断输入的是这个表情?有没有解决办法,请指教?
2019-05-14 - Licia 支持小程序的 JS 工具库
导语 Licia 是一套在开发中实践积累起来的实用 JavaScript 工具库。该库目前拥有超过 300 个模块,同时支持浏览器、node 及小程序运行环境,提供了包括日期格式化、md5、颜色转换等实用模块,可以极大地提高开发效率。 前言 因为小程序运行的是 JavaScript 代码,传统前端所使用的 JS 库理应也能够被用在小程序中才对。然而,经过实际测试,你会发现有相当一部分 npm 包是无法直接在小程序中跑起来的。比如前端工程师十分常用的 lodash,在小程序中引入会报错。 为什么会这样? 主要原因就是绝大部分库的开发者在设计时只会考虑两种运行环境,浏览器和 node,而小程序并不会在其考虑范围内。因此,只要开发者的 JS 代码使用了只有浏览器与 node 中才有的接口,如 DOM 操作、文件读写等,该库就不能正常地运行在小程序环境中。除此之外,假如他们使用了小程序禁用的功能,例如全局变量与动态代码执行,这时候代码跑在小程序环境也会出错。 使用 使用 npm 安装 1、 安装 npm 包 [代码]npm i miniprogram-licia --save [代码] 2、点击开发者工具中的菜单栏:工具 --> 构建 npm 3、直接在代码中引入使用 [代码]const licia = require('miniprogram-licia'); licia.md5('licia'); // -> 'e59f337d85e9a467f1783fab282a41d0' licia.safeGet({a: {b: 1}}, 'a.b'); // -> 1 [代码] 生成定制化 util.js 使用 npm 包的方式会将所有功能引入到代码包中,大概会增加 100 kb 的大小。如果你只想引入所需脚本,可以使用在线工具生成定制化 util 库。 1、访问 https://licia.liriliri.io/builder.html 2、输入需要的模块名,点击生成下载 util.js。 3、将生成的工具库拷贝到小程序项目任意目录下然后直接引入使用。 [代码]const util = require('../lib/util'); util.wx.getStorage({ key: 'test' }).then(res => console.log(res.data)); [代码] 优点 1、目前拥有 270 多个模块可在小程序中正常运行,而 underscore 只有 120 个函数左右。 2、与 lodash 相比增加了不少更加实用的函数,比如 md5、atob、btoa、Emitter、dateFormat 等。 3、可以直接在小程序中引入运行,不像 lodash 需要进行一定的修改才能正常跑在小程序中。 4、定制化生成可以使用更小体积的工具库,这在限制了代码包大小的小程序中十分有用。 附录 这里只简单列出函数及其功能介绍,详细的用法请访问官网查看。 注:模块名右边有小程序图标即表明可以在小程序中使用。 Class: 创建 JavaScript 类。 Color: 颜色转换。 Dispatcher: Flux 调度器。 Emitter: 提供观察者模式的 Event emitter 类。 Enum: Enum 类实现。 JsonTransformer: JSON 转换器。 LinkedList: 双向链表实现。 Logger: 带日志级别的简单日志库。 Lru: 简单 LRU 缓存。 Promise: 轻量 Promise 实现。 PseudoMap: 类似 es6 的 Map,不支持遍历器。 Queue: 队列数据结构。 QuickLru: 不使用链表的 LRU 实现。 ReduceStore: 简单类 redux 状态管理。 Stack: 栈数据结构。 State: 简单状态机。 Store: 内存存储。 Tween: JavaScript 补间动画库。 Url: 简单 url 操作库。 Validator: 对象属性值校验。 abbrev: 计算字符串集的缩写集合。 after: 创建一个函数,只有在调用 n 次后才会调用一次。 allKeys: 获取对象的所有键名,包括自身的及继承的。 arrToMap: 将字符串列表转换为映射。 atob: window.atob,运行在 node 环境时使用 Buffer 进行模拟。 average: 获取数字的平均值。 base64: base64 编解码。 before: 创建一个函数,只能调用少于 n 次。 binarySearch: 二分查找实现。 bind: 创建一个绑定到指定对象的函数。 btoa: window.btoa,运行在 node 环境时使用 Buffer 进行模拟。 bubbleSort: 冒泡排序实现。 bytesToStr: 将字节数组转换为字符串。 callbackify: 将返回 Promise 的函数转换为使用回调的函数。 camelCase: 将字符串转换为驼峰式。 capitalize: 将字符串的第一个字符转换为大写,其余字符转换为小写。 castPath: 将值转换为属性路径数组。 centerAlign: 字符串居中。 char: 根据指定的整数返回 unicode 编码为该整数的字符。 chunk: 将数组拆分为指定长度的子数组。 clamp: 将数字限定于指定区间。 className: 合并 class。 clone: 对指定对象进行浅复制。 cloneDeep: 深复制。 cmpVersion: 比较版本号。 combine: 创建一个数组,用一个数组的值作为其键名,另一个数组的值作为其值。 compact: 返回数组的拷贝并移除其中的虚值。 compose: 将多个函数组合成一个函数。 concat: 将多个数组合并成一个数组。 contain: 检查数组中是否有指定值。 convertBase: 对数字进行进制转换。 createAssigner: 用于创建 extend,extendOwn 和 defaults 等模块。 curry: 函数柯里化。 dateFormat: 简单日期格式化。 debounce: 返回函数的防反跳版本。 decodeUriComponent: 类似 decodeURIComponent 函数,只是输入不合法时不抛出错误并尽可能地对其进行解码。 defaults: 填充对象的默认值。 define: 定义一个模块,需要跟 use 模块配合使用。 defineProp: Object.defineProperty(defineProperties) 的快捷方式。 delay: 在指定时长后执行函数。 detectBrowser: 使用 ua 检测浏览器信息。 detectMocha: 检测是否有 mocha 测试框架在运行。 detectOs: 使用 ua 检测操作系统。 difference: 创建一个数组,该数组的元素不存在于给定的其它数组中。 dotCase: 将字符串转换为点式。 each: 遍历集合中的所有元素,用每个元素当做参数调用迭代器。 easing: 缓动函数,参考 http://jqueryui.com/ 。 endWith: 检查字符串是否以指定字符串结尾。 escape: 转义 HTML 字符串,替换 &,<,>,",`,和 ’ 字符。 escapeJsStr: 转义字符串为合法的 JavaScript 字符串字面量。 escapeRegExp: 转义特殊字符用于 RegExp 构造函数。 every: 检查是否集合中的所有元素都能通过真值检测。 extend: 复制多个对象中的所有属性到目标对象上。 extendDeep: 类似 extend,但会递归进行扩展。 extendOwn: 类似 extend,但只复制自己的属性,不包括原型链上的属性。 extractBlockCmts: 从源码中提取块注释。 extractUrls: 从文本中提取 url。 fibonacci: 计算斐波那契数列中某位数字。 fileSize: 将字节数转换为易于阅读的形式。 fill: 在数组指定位置填充指定值。 filter: 遍历集合中的每个元素,返回所有通过真值检测的元素组成的数组。 find: 找到集合中第一个通过真值检测的元素。 findIdx: 返回第一个通过真值检测元素在数组中的位置。 findKey: 返回对象中第一个通过真值检测的属性键名。 findLastIdx: 同 findIdx,只是查找顺序改为从后往前。 flatten: 递归拍平数组。 fnParams: 获取函数的参数名列表。 format: 使用类似于 printf 的方式来格式化字符串。 fraction: 转换数字为分数形式。 freeze: Object.freeze 的快捷方式。 freezeDeep: 递归进行 Object.freeze。 gcd: 使用欧几里德算法求最大公约数。 getUrlParam: 获取 url 参数值。 has: 检查属性是否是对象自身的属性(原型链上的不算)。 hslToRgb: 将 hsl 格式的颜色值转换为 rgb 格式。 identity: 返回传入的第一个参数。 idxOf: 返回指定值第一次在数组中出现的位置。 indent: 对文本的每一行进行缩进处理。 inherits: 使构造函数继承另一个构造函数原型链上的方法。 insertionSort: 插入排序实现。 intersect: 计算所有数组的交集。 intersectRange: 计算两个区间的交集。 invert: 生成一个新对象,该对象的键名和键值进行调换。 isAbsoluteUrl: 检查 url 是否是绝对地址。 isArgs: 检查值是否是参数类型。 isArr: 检查值是否是数组类型。 isArrBuffer: 检查值是否是 ArrayBuffer 类型。 isArrLike: 检查值是否是类数组对象。 isBool: 检查值是否是布尔类型。 isBrowser: 检测是否运行于浏览器环境。 isClose: 检查两个数字是否近似相等。 isDataUrl: 检查字符串是否是有效的 Data Url。 isDate: 检查值是否是 Date 类型。 isEmail: 简单检查值是否是合法的邮件地址。 isEmpty: 检查值是否是空对象或空数组。 isEqual: 对两个对象进行深度比较,如果相等,返回真。 isErr: 检查值是否是 Error 类型。 isEven: 检查数字是否是偶数。 isFinite: 检查值是否是有限数字。 isFn: 检查值是否是函数。 isGeneratorFn: 检查值是否是 Generator 函数。 isInt: 检查值是否是整数。 isJson: 检查值是否是有效的 JSON。 isLeapYear: 检查年份是否是闰年。 isMap: 检查值是否是 Map 对象。 isMatch: 检查对象所有键名和键值是否在指定的对象中。 isMiniProgram: 检测是否运行于微信小程序环境中。 isMobile: 使用 ua 检测是否运行于移动端浏览器。 isNaN: 检测值是否是 NaN。 isNative: 检查值是否是原生函数。 isNil: 检查值是否是 null 或 undefined,等价于 value == null。 isNode: 检测是否运行于 node 环境中。 isNull: 检查值是否是 Null 类型。 isNum: 检测值是否是数字类型。 isNumeric: 检查值是否是数字,包括数字字符串。 isObj: 检查值是否是对象。 isOdd: 检查数字是否是奇数。 isPlainObj: 检查值是否是用 Object 构造函数创建的对象。 isPrime: 检查整数是否是质数。 isPrimitive: 检测值是否是字符串,数字,布尔值或 null。 isPromise: 检查值是否是类 promise 对象。 isRegExp: 检查值是否是正则类型。 isRelative: 检查路径是否是相对路径。 isSet: 检查值是否是 Set 类型。 isSorted: 检查数组是否有序。 isStr: 检查值是否是字符串。 isTypedArr: 检查值是否 TypedArray 类型。 isUndef: 检查值是否是 undefined。 isUrl: 简单检查值是否是有效的 url 地址。 isWeakMap: 检查值是否是 WeakMap 类型。 isWeakSet: 检查值是否是 WeakSet 类型。 kebabCase: 将字符串转换为短横线式。 keyCode: 键码键名转换。 keys: 返回包含对象自身可遍历所有键名的数组。 last: 获取数组的最后一个元素。 linkify: 将文本中的 url 地址转换为超链接。 longest: 获取数组中最长的一项。 lowerCase: 转换字符串为小写。 lpad: 对字符串进行左填充。 ltrim: 删除字符串头部指定字符或空格。 map: 对集合的每个元素调用转换函数生成与之对应的数组。 mapObj: 类似 map,但针对对象,生成一个新对象。 matcher: 传入对象返回函数,如果传入参数中包含该对象则返回真。 max: 获取数字中的最大值。 md5: MD5 算法实现。 memStorage: Web Storage 接口的纯内存实现。 memoize: 缓存函数计算结果。 mergeSort: 归并排序实现。 methods: 获取对象中所有方法名。 min: 获取数字中的最小值。 moment: 简单的类 moment.js 实现。 ms: 时长字符串与毫秒转换库。 negate: 创建一个将原函数结果取反的函数。 nextTick: 能够同时运行在 node 和浏览器端的 next tick 实现。 noop: 一个什么也不做的空函数。 normalizeHeader: 标准化 HTTP 头部名。 normalizePath: 标准化文件路径中的斜杠。 now: 获取当前时间戳。 objToStr: Object.prototype.toString 的别名。 omit: 类似 pick,但结果相反。 once: 创建只能调用一次的函数。 optimizeCb: 用于高效的函数上下文绑定。 pad: 对字符串进行左右填充。 pairs: 将对象转换为包含【键名,键值】对的数组。 parallel: 同时执行多个函数。 parseArgs: 命令行参数简单解析。 partial: 返回局部填充参数的函数,与 bind 模块相似。 pascalCase: 将字符串转换为帕斯卡式。 perfNow: 高精度时间戳。 pick: 过滤对象。 pluck: 提取数组对象中指定属性值,返回一个数组。 precision: 获取数字的精度。 promisify: 转换使用回调的异步函数,使其返回 Promise。 property: 返回一个函数,该函数返回任何传入对象的指定属性。 query: 解析序列化 url 的 query 部分。 quickSort: 快排实现。 raf: requestAnimationFrame 快捷方式。 random: 在给定区间内生成随机数。 randomItem: 随机获取数组中的某项。 range: 创建整数数组。 rc4: RC4 对称加密算法实现。 reduce: 合并多个值成一个值。 reduceRight: 类似于 reduce,只是从后往前合并。 reject: 类似 filter,但结果相反。 remove: 移除集合中所有通过真值检测的元素,返回包含所有删除元素的数组。 repeat: 重复字符串指定次数。 restArgs: 将给定序号后的参数合并成一个数组。 rgbToHsl: 将 rgb 格式的颜色值转换为 hsl 格式。 root: 根对象引用,对于 nodeJs,取 [代码]global[代码] 对象,对于浏览器,取 [代码]window[代码] 对象。 rpad: 对字符串进行右填充。 rtrim: 删除字符串尾部指定字符或空格。 safeCb: 创建回调函数,内部模块使用。 safeDel: 删除对象属性。 safeGet: 获取对象属性值,路径不存在时不报错。 safeSet: 设置对象属性值。 sample: 从集合中随机抽取部分样本。 selectionSort: 选择排序实现。 shuffle: 将数组中元素的顺序打乱。 size: 获取对象的大小或类数组元素的长度。 sleep: 使用 Promise 模拟暂停方法。 slice: 截取数组的一部分生成新数组。 snakeCase: 转换字符串为下划线式。 some: 检查集合中是否有元素通过真值检测。 sortBy: 遍历集合中的元素,将其作为参数调用函数,并以得到的结果为依据对数组进行排序。 spaceCase: 将字符串转换为空格式。 splitCase: 将不同命名式的字符串拆分成数组。 splitPath: 将路径拆分为文件夹路径,文件名和扩展名。 startWith: 检查字符串是否以指定字符串开头。 strHash: 使用 djb2 算法进行字符串哈希。 strToBytes: 将字符串转换为字节数组。 stringify: JSON 序列化,支持循环引用和函数。 stripAnsi: 清除字符串中的 ansi 控制码。 stripCmt: 清除源码中的注释。 stripColor: 清除字符串中的 ansi 颜色控制码。 stripHtmlTag: 清除字符串中的 html 标签。 sum: 计算数字和。 swap: 交换数组中的两项。 template: 将模板字符串编译成函数用于渲染。 throttle: 返回函数的节流阀版本。 timeAgo: 将时间格式化成多久之前的形式。 timeTaken: 获取函数的执行时间。 times: 调用目标函数 n 次。 toArr: 将任意值转换为数组。 toBool: 将任意值转换为布尔值。 toDate: 将任意值转换为日期类型。 toInt: 将任意值转换为整数。 toNum: 将任意值转换为数字。 toSrc: 将函数转换为源码。 toStr: 将任意值转换为字符串。 topoSort: 拓扑排序实现。 trim: 删除字符串两边指定字符或空格。 tryIt: 在 try catch 块中运行函数。 type: 获取 JavaScript 对象的内部类型。 types: 仅用于生成 ts 定义文件。 ucs2: UCS-2 编解码。 unescape: 和 escape 相反,转义 HTML 实体回去。 union: 返回传入所有数组的并集。 uniqId: 生成全局唯一 id。 unique: 返回数组去重后的副本。 unzip: 与 zip 相反。 upperCase: 转换字符串为大写。 upperFirst: 将字符串的第一个字符转换为大写。 use: 使用 define 创建的模块。 utf8: UTF-8 编解码。 values: 返回对象所有的属性值。 vlq: vlq 编解码。 waitUntil: 等待直到条件函数返回真值。 waterfall: 按顺序执行函数序列。 wrap: 将函数封装到包裹函数里面, 并把它作为第一个参数传给包裹函数。 wx: 小程序 wx 对象的 promise 版本。 zip: 将每个数组中相应位置的值合并在一起。
2019-05-07 - Vue 服务端渲染实践 ——Web应用首屏耗时最优化方案
随着各大前端框架的诞生和演变,[代码]SPA[代码]开始流行,单页面应用的优势在于可以不重新加载整个页面的情况下,通过[代码]ajax[代码]和服务器通信,实现整个[代码]Web[代码]应用拒不更新,带来了极致的用户体验。然而,对于需要[代码]SEO[代码]、追求极致的首屏性能的应用,前端渲染的[代码]SPA[代码]是糟糕的。好在[代码]Vue 2.0[代码]后是支持服务端渲染的,零零散散花费了两三周事件,通过改造现有项目,基本完成了在现有项目中实践了[代码]Vue[代码]服务端渲染。 关于Vue服务端渲染的原理、搭建,官方文档已经讲的比较详细了,因此,本文不是抄袭文档,而是文档的补充。特别是对于如何与现有项目进行很好的结合,还是需要费很大功夫的。本文主要对我所在的项目中进行[代码]Vue[代码]服务端渲染的改造过程进行阐述,加上一些个人的理解,作为分享与学习。 概述 本文主要分以下几个方面: 什么是服务端渲染?服务端渲染的原理是什么? 如何在基于[代码]Koa[代码]的[代码]Web Server Frame[代码]上配置服务端渲染? 基本用法 [代码]Webpack[代码]配置 开发环境搭建 渲染中间件配置 如何对现有项目进行改造? 基本目录改造; 在服务端用[代码]vue-router[代码]分割代码; 在服务端预拉取数据; 客户端托管全局状态; 常见问题的解决方案; 什么是服务端渲染?服务端渲染的原理是什么? [代码]Vue.js[代码]是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出[代码]Vue[代码]组件,进行生成[代码]DOM[代码]和操作[代码]DOM[代码]。然而,也可以将同一个组件渲染为服务器端的[代码]HTML[代码]字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。 上面这段话是源自Vue服务端渲染文档的解释,用通俗的话来说,大概可以这么理解: 服务端渲染的目的是:性能优势。 在服务端生成对应的[代码]HTML[代码]字符串,客户端接收到对应的[代码]HTML[代码]字符串,能立即渲染[代码]DOM[代码],最高效的首屏耗时。此外,由于服务端直接生成了对应的[代码]HTML[代码]字符串,对[代码]SEO[代码]也非常友好; 服务端渲染的本质是:生成应用程序的“快照”。将[代码]Vue[代码]及对应库运行在服务端,此时,[代码]Web Server Frame[代码]实际上是作为代理服务器去访问接口服务器来预拉取数据,从而将拉取到的数据作为[代码]Vue[代码]组件的初始状态。 服务端渲染的原理是:虚拟[代码]DOM[代码]。在[代码]Web Server Frame[代码]作为代理服务器去访问接口服务器来预拉取数据后,这是服务端初始化组件需要用到的数据,此后,组件的[代码]beforeCreate[代码]和[代码]created[代码]生命周期会在服务端调用,初始化对应的组件后,[代码]Vue[代码]启用虚拟[代码]DOM[代码]形成初始化的[代码]HTML[代码]字符串。之后,交由客户端托管。实现前后端同构应用。 如何在基于[代码]Koa[代码]的[代码]Web Server Frame[代码]上配置服务端渲染? 基本用法 需要用到[代码]Vue[代码]服务端渲染对应库[代码]vue-server-renderer[代码],通过[代码]npm[代码]安装: [代码]npm install vue vue-server-renderer --save [代码] 最简单的,首先渲染一个[代码]Vue[代码]实例: [代码] // 第 1 步:创建一个 Vue 实例 const Vue = require('vue'); const app = new Vue({ template: `<div>Hello World</div>` }); // 第 2 步:创建一个 renderer const renderer = require('vue-server-renderer').createRenderer(); // 第 3 步:将 Vue 实例渲染为 HTML renderer.renderToString(app, (err, html) => { if (err) { throw err; } console.log(html); // => <div data-server-rendered="true">Hello World</div> }); [代码] 与服务器集成: [代码] module.exports = async function(ctx) { ctx.status = 200; let html = ''; try { // ... html = await renderer.renderToString(app, ctx); } catch (err) { ctx.logger('Vue SSR Render error', JSON.stringify(err)); html = await ctx.getErrorPage(err); // 渲染出错的页面 } ctx.body = html; } [代码] 使用页面模板: 当你在渲染[代码]Vue[代码]应用程序时,[代码]renderer[代码]只从应用程序生成[代码]HTML[代码]标记。在这个示例中,我们必须用一个额外的[代码]HTML[代码]页面包裹容器,来包裹生成的[代码]HTML[代码]标记。 为了简化这些,你可以直接在创建[代码]renderer[代码]时提供一个页面模板。多数时候,我们会将页面模板放在特有的文件中: [代码]<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html> [代码] 然后,我们可以读取和传输文件到[代码]Vue renderer[代码]中: [代码]const tpl = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8'); const renderer = vssr.createRenderer({ template: tpl, }); [代码] Webpack配置 然而在实际项目中,不止上述例子那么简单,需要考虑很多方面:路由、数据预取、组件化、全局状态等,所以服务端渲染不是只用一个简单的模板,然后加上使用[代码]vue-server-renderer[代码]完成的,如下面的示意图所示: [图片] 如示意图所示,一般的[代码]Vue[代码]服务端渲染项目,有两个项目入口文件,分别为[代码]entry-client.js[代码]和[代码]entry-server.js[代码],一个仅运行在客户端,一个仅运行在服务端,经过[代码]Webpack[代码]打包后,会生成两个[代码]Bundle[代码],服务端的[代码]Bundle[代码]会用于在服务端使用虚拟[代码]DOM[代码]生成应用程序的“快照”,客户端的[代码]Bundle[代码]会在浏览器执行。 因此,我们需要两个[代码]Webpack[代码]配置,分别命名为[代码]webpack.client.config.js[代码]和[代码]webpack.server.config.js[代码],分别用于生成客户端[代码]Bundle[代码]与服务端[代码]Bundle[代码],分别命名为[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码],关于如何配置,[代码]Vue[代码]官方有相关示例vue-hackernews-2.0 开发环境搭建 我所在的项目使用[代码]Koa[代码]作为[代码]Web Server Frame[代码],项目使用koa-webpack进行开发环境的构建。如果是在产品环境下,会生成[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码],包含对应的[代码]Bundle[代码],提供客户端和服务端引用,而在开发环境下,一般情况下放在内存中。使用[代码]memory-fs[代码]模块进行读取。 [代码]const fs = require('fs') const path = require( 'path' ); const webpack = require( 'webpack' ); const koaWpDevMiddleware = require( 'koa-webpack' ); const MFS = require('memory-fs'); const appSSR = require('./../../app.ssr.js'); let wpConfig; let clientConfig, serverConfig; let wpCompiler; let clientCompiler, serverCompiler; let clientManifest; let bundle; // 生成服务端bundle的webpack配置 if ((fs.existsSync(path.resolve(cwd,'webpack.server.config.js')))) { serverConfig = require(path.resolve(cwd, 'webpack.server.config.js')); serverCompiler = webpack( serverConfig ); } // 生成客户端clientManifest的webpack配置 if ((fs.existsSync(path.resolve(cwd,'webpack.client.config.js')))) { clientConfig = require(path.resolve(cwd, 'webpack.client.config.js')); clientCompiler = webpack(clientConfig); } if (serverCompiler && clientCompiler) { let publicPath = clientCompiler.output && clientCompiler.output.publicPath; const koaDevMiddleware = await koaWpDevMiddleware({ compiler: clientCompiler, devMiddleware: { publicPath, serverSideRender: true }, }); app.use(koaDevMiddleware); // 服务端渲染生成clientManifest app.use(async (ctx, next) => { const stats = ctx.state.webpackStats.toJson(); const assetsByChunkName = stats.assetsByChunkName; stats.errors.forEach(err => console.error(err)); stats.warnings.forEach(err => console.warn(err)); if (stats.errors.length) { console.error(stats.errors); return; } // 生成的clientManifest放到appSSR模块,应用程序可以直接读取 let fileSystem = koaDevMiddleware.devMiddleware.fileSystem; clientManifest = JSON.parse(fileSystem.readFileSync(path.resolve(cwd,'./dist/vue-ssr-client-manifest.json'), 'utf-8')); appSSR.clientManifest = clientManifest; await next(); }); // 服务端渲染的server bundle 存储到内存里 const mfs = new MFS(); serverCompiler.outputFileSystem = mfs; serverCompiler.watch({}, (err, stats) => { if (err) { throw err; } stats = stats.toJson(); if (stats.errors.length) { console.error(stats.errors); return; } // 生成的bundle放到appSSR模块,应用程序可以直接读取 bundle = JSON.parse(mfs.readFileSync(path.resolve(cwd,'./dist/vue-ssr-server-bundle.json'), 'utf-8')); appSSR.bundle = bundle; }); } [代码] 渲染中间件配置 产品环境下,打包后的客户端和服务端的[代码]Bundle[代码]会存储为[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码],通过文件流模块[代码]fs[代码]读取即可,但在开发环境下,我创建了一个[代码]appSSR[代码]模块,在发生代码更改时,会触发[代码]Webpack[代码]热更新,[代码]appSSR[代码]对应的[代码]bundle[代码]也会更新,[代码]appSSR[代码]模块代码如下所示: [代码]let clientManifest; let bundle; const appSSR = { get bundle() { return bundle; }, set bundle(val) { bundle = val; }, get clientManifest() { return clientManifest; }, set clientManifest(val) { clientManifest = val; } }; module.exports = appSSR; [代码] 通过引入[代码]appSSR[代码]模块,在开发环境下,就可以拿到[代码]clientManifest[代码]和[代码]ssrBundle[代码],项目的渲染中间件如下: [代码]const fs = require('fs'); const path = require('path'); const ejs = require('ejs'); const vue = require('vue'); const vssr = require('vue-server-renderer'); const createBundleRenderer = vssr.createBundleRenderer; const dirname = process.cwd(); const siteInfo = require('./../../core/siteinfo.js').get(); const env = siteInfo.env; let bundle; let clientManifest; if (env === 'development') { // 开发环境下,通过appSSR模块,拿到clientManifest和ssrBundle let appSSR = require('./../../core/app.ssr.js'); bundle = appSSR.bundle; clientManifest = appSSR.clientManifest; } else { bundle = JSON.parse(fs.readFileSync(path.resolve(__dirname, './dist/vue-ssr-server-bundle.json'), 'utf-8')); clientManifest = JSON.parse(fs.readFileSync(path.resolve(__dirname, './dist/vue-ssr-client-manifest.json'), 'utf-8')); } module.exports = async function(ctx) { ctx.status = 200; let html; let context = await ctx.getTplContext(); ctx.logger('进入SSR,context为: ', JSON.stringify(context)); const tpl = fs.readFileSync(path.resolve(__dirname, './newTemplate.html'), 'utf-8'); const renderer = createBundleRenderer(bundle, { runInNewContext: false, template: tpl, // (可选)页面模板 clientManifest: clientManifest // (可选)客户端构建 manifest }); ctx.logger('createBundleRenderer renderer:', JSON.stringify(renderer)); try { html = await renderer.renderToString({ ...context, url: context.CTX.url, }); } catch(err) { ctx.logger('SSR renderToString 失败: ', JSON.stringify(err)); console.error(err); } ctx.body = html; }; [代码] 如何对现有项目进行改造? 基本目录改造 使用[代码]Webpack[代码]来处理服务器和客户端的应用程序,大部分源码可以使用通用方式编写,可以使用[代码]Webpack[代码]支持的所有功能。 一个基本项目可能像是这样: [代码]src ├── components │ ├── Foo.vue │ ├── Bar.vue │ └── Baz.vue ├── frame │ ├── app.js # 通用 entry(universal entry) │ ├── entry-client.js # 仅运行于浏览器 │ ├── entry-server.js # 仅运行于服务器 │ └── index.vue # 项目入口组件 ├── pages ├── routers └── store [代码] [代码]app.js[代码]是我们应用程序的「通用[代码]entry[代码]」。在纯客户端应用程序中,我们将在此文件中创建根[代码]Vue[代码]实例,并直接挂载到[代码]DOM[代码]。但是,对于服务器端渲染([代码]SSR[代码]),责任转移到纯客户端[代码]entry[代码]文件。[代码]app.js[代码]简单地使用[代码]export[代码]导出一个[代码]createApp[代码]函数: [代码]import Router from '~ut/router'; import { sync } from 'vuex-router-sync'; const Vue = require('vue'); const { createStore } = require('./../store'); import ElementUI from 'element-ui'; import vueScroll from 'vue-scroll'; import '~mstyle/ai-scan/src/assets/less/common.less'; import Frame from './index.vue'; import breastRouter from './../routers/breast'; import lungRouter from './../routers/lung'; import List from './../page/list/index.vue'; import DicomView from '~cmpt/dicom-view'; Vue.use(vueScroll); Vue.use(ElementUI); function createVueInstance(routes, ctx) { let siteinfo; if (ctx) { siteinfo = ctx.siteinfo; } else { siteinfo = window.SiteInfo; } const router = Router({ base: siteinfo.path, mode: 'history', routes: [routes], }); const store = createStore({ ctx }); // 把路由注入到vuex中 sync(store, router); const app = new Vue({ router, render: function(h) { return h(Frame); }, store, }); return { app, router, store }; } function createVueInstanceNoRouter(component, ctx) { const componentIns = Vue.extend(component); const store = createStore({ ctx }); const app = new Vue({ store, render: h => h(component), }); return { app, store }; } module.exports = function createApp(ctx) { // 通过typeof window是否为'undefined'判断当前是客户端还是服务端 // 针对不同页面创建不同的Vue实例 if (typeof window !== 'undefined') { // 客户端 if (/list/.test(window.location.pathname)) { return createVueInstanceNoRouter(List); } else if (/breast/.test(window.location.pathname)) { DicomView.init('image', 'imaging'); return createVueInstance(breastRouter); } else if (/lung/.test(window.location.pathname)) { DicomView.init('series', 'imaging'); return createVueInstance(lungRouter); } else if (/pacs/.test(window.location.pathname)) { DicomView.init('series', 'imaging'); return createVueInstance(pacsRouter); } else if (/esophagus/.test(window.location.pathname)) { return createVueInstance(esophagusRouter); } else { return void console.log('The path does not match any router rule'); // do nothing } } else { // 服务端 if (/list/.test(ctx.path)) { return createVueInstanceNoRouter(List, ctx); } else if (/lung/.test(ctx.path)) { return createVueInstance(lungRouter, ctx); } else if (/pacs/.test(ctx.path)) { return createVueInstance(pacsRouter); } else if (/esophagus/.test(ctx.path)) { return createVueInstance(esophagusRouter); } else if (/breast/.test(ctx.path)) { return createVueInstance(breastRouter, ctx); } else { return void console.log('The path does not match any router rule'); // do nothing } } } [代码] 注:在我所在的项目中,需要动态判断是否需要注册[代码]DicomView[代码],只有在客户端才初始化[代码]DicomView[代码],由于[代码]Node.js[代码]环境没有[代码]window[代码]对象,对于代码运行环境的判断,可以通过[代码]typeof window === 'undefined'[代码]来进行判断。 避免创建单例 如[代码]Vue SSR[代码]文档所述: 当编写纯客户端 (client-only) 代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。如基本示例所示,我们为每个请求创建一个新的根 Vue 实例。这与每个用户在自己的浏览器中使用新应用程序的实例类似。如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染 (cross-request state pollution)。因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例。同样的规则也适用于 router、store 和 event bus 实例。你不应该直接从模块导出并将其导入到应用程序中,而是需要在 createApp 中创建一个新的实例,并从根 Vue 实例注入。 如上代码所述,[代码]createApp[代码]方法通过返回一个返回值创建[代码]Vue[代码]实例的对象的函数调用,在函数[代码]createVueInstance[代码]中,为每一个请求创建了[代码]Vue[代码],[代码]Vue Router[代码],[代码]Vuex[代码]实例。并暴露给[代码]entry-client[代码]和[代码]entry-server[代码]模块。 在客户端[代码]entry-client.js[代码]只需创建应用程序,并且将其挂载到[代码]DOM[代码]中: [代码]import { createApp } from './app'; // 客户端特定引导逻辑…… const { app } = createApp(); // 这里假定 App.vue 模板中根元素具有 `id="app"` app.$mount('#app'); [代码] 服务端[代码]entry-server.js[代码]使用[代码]default export[代码] 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 - 但是稍后我们将在此执行服务器端路由匹配和数据预取逻辑: [代码]import { createApp } from './app'; export default context => { const { app } = createApp(); return app; } [代码] 在服务端用[代码]vue-router[代码]分割代码 与[代码]Vue[代码]实例一样,也需要创建单例的[代码]vueRouter[代码]对象。对于每个请求,都需要创建一个新的[代码]vueRouter[代码]实例: [代码]function createVueInstance(routes, ctx) { let siteinfo; if (ctx) { siteinfo = ctx.siteinfo; } else { siteinfo = window.SiteInfo; } const router = Router({ base: siteinfo.path, mode: 'history', routes: [routes], }); const store = createStore({ ctx }); // 把路由注入到vuex中 sync(store, router); const app = new Vue({ router, render: function(h) { return h(Frame); }, store, }); return { app, router, store }; } [代码] 同时,需要在[代码]entry-server.js[代码]中实现服务器端路由逻辑,使用[代码]router.getMatchedComponents[代码]方法获取到当前路由匹配的组件,如果当前路由没有匹配到相应的组件,则[代码]reject[代码]到[代码]404[代码]页面,否则[代码]resolve[代码]整个[代码]app[代码],用于[代码]Vue[代码]渲染虚拟[代码]DOM[代码],并使用对应模板生成对应的[代码]HTML[代码]字符串。 [代码]const createApp = require('./app'); module.exports = context => { return new Promise((resolve, reject) => { // ... // 设置服务器端 router 的位置 router.push(context.url); // 等到 router 将可能的异步组件和钩子函数解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,执行 reject 函数,并返回 404 if (!matchedComponents.length) { return reject('匹配不到的路由,执行 reject 函数,并返回 404'); } // Promise 应该 resolve 应用程序实例,以便它可以渲染 resolve(app); }, reject); }); } [代码] 在服务端预拉取数据 在[代码]Vue[代码]服务端渲染,本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。服务端[代码]Web Server Frame[代码]作为代理服务器,在服务端对接口服务发起请求,并将数据拼装到全局[代码]Vuex[代码]状态中。 另一个需要关注的问题是在客户端,在挂载到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。 目前较好的解决方案是,给路由匹配的一级子组件一个[代码]asyncData[代码],在[代码]asyncData[代码]方法中,[代码]dispatch[代码]对应的[代码]action[代码]。[代码]asyncData[代码]是我们约定的函数名,表示渲染组件需要预先执行它获取初始数据,它返回一个[代码]Promise[代码],以便我们在后端渲染的时候可以知道什么时候该操作完成。注意,由于此函数会在组件实例化之前调用,所以它无法访问[代码]this[代码]。需要将[代码]store[代码]和路由信息作为参数传递进去: 举个例子: [代码]<!-- Lung.vue --> <template> <div></div> </template> <script> export default { // ... async asyncData({ store, route }) { return Promise.all([ store.dispatch('getUserInfo'), store.dispatch('lung/getSeriesByStudyId', { studyId: route.params.id, aiEngine: route.params.aiEngine, }, { root:true }), store.dispatch('lung/getDicomViewConfig', { root:true }), store.dispatch('lung/getDialogWindow', { root:true }), ]); }, // ... } </script> [代码] 在[代码]entry-server.js[代码]中,我们可以通过路由获得与[代码]router.getMatchedComponents()[代码]相匹配的组件,如果组件暴露出[代码]asyncData[代码],我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文中。 [代码]const createApp = require('./app'); module.exports = context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp(context.CTX); // 针对没有Vue router 的Vue实例,在项目中为列表页,直接resolve app if (!router) { resolve(app); } // 设置服务器端 router 的位置 router.push(context.CTX.url.replace('/imaging', '')); // 等到 router 将可能的异步组件和钩子函数解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,执行 reject 函数,并返回 404 if (!matchedComponents.length) { return reject('匹配不到的路由,执行 reject 函数,并返回 404'); } Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute, }); } })).then(() => { // 在所有预取钩子(preFetch hook) resolve 后, // 我们的 store 现在已经填充入渲染应用程序所需的状态。 // 当我们将状态附加到上下文,并且 `template` 选项用于 renderer 时, // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state; resolve(app); }).catch(reject); }, reject); }); } [代码] 客户端托管全局状态 当服务端使用模板进行渲染时,[代码]context.state[代码]将作为[代码]window.__INITIAL_STATE__[代码]状态,自动嵌入到最终的[代码]HTML[代码] 中。而在客户端,在挂载到应用程序之前,[代码]store[代码]就应该获取到状态,最终我们的[代码]entry-client.js[代码]被改造为如下所示: [代码]import createApp from './app'; const { app, router, store } = createApp(); // 客户端把初始化的store替换为window.__INITIAL_STATE__ if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } if (router) { router.onReady(() => { app.$mount('#app') }); } else { app.$mount('#app'); } [代码] 常见问题的解决方案 至此,基本的代码改造也已经完成了,下面说的是一些常见问题的解决方案: 在服务端没有[代码]window[代码]、[代码]location[代码]对象: 对于旧项目迁移到[代码]SSR[代码]肯定会经历的问题,一般为在项目入口处或是[代码]created[代码]、[代码]beforeCreate[代码]生命周期使用了[代码]DOM[代码]操作,或是获取了[代码]location[代码]对象,通用的解决方案一般为判断执行环境,通过[代码]typeof window[代码]是否为[代码]'undefined'[代码],如果遇到必须使用[代码]location[代码]对象的地方用于获取[代码]url[代码]中的相关参数,在[代码]ctx[代码]对象中也可以找到对应参数。 [代码]vue-router[代码]报错[代码]Uncaught TypeError: _Vue.extend is not _Vue function[代码],没有找到[代码]_Vue[代码]实例的问题: 通过查看[代码]Vue-router[代码]源码发现没有手动调用[代码]Vue.use(Vue-Router);[代码]。没有调用[代码]Vue.use(Vue-Router);[代码]在浏览器端没有出现问题,但在服务端就会出现问题。对应的[代码]Vue-router[代码]源码所示: [代码]VueRouter.prototype.init = function init (app /* Vue component instance */) { var this$1 = this; process.env.NODE_ENV !== 'production' && assert( install.installed, "not installed. Make sure to call `Vue.use(VueRouter)` " + "before creating root instance." ); // ... } [代码] 服务端无法获取[代码]hash[代码]路由的参数 由于[代码]hash[代码]路由的参数,会导致[代码]vue-router[代码]不起效果,对于使用了[代码]vue-router[代码]的前后端同构应用,必须换为[代码]history[代码]路由。 接口处获取不到[代码]cookie[代码]的问题: 由于客户端每次请求都会对应地把[代码]cookie[代码]带给接口侧,而服务端[代码]Web Server Frame[代码]作为代理服务器,并不会每次维持[代码]cookie[代码],所以需要我们手动把 [代码]cookie[代码]透传给接口侧,常用的解决方案是,将[代码]ctx[代码]挂载到全局状态中,当发起异步请求时,手动带上[代码]cookie[代码],如下代码所示: [代码]// createStore.js // 在创建全局状态的函数`createStore`时,将`ctx`挂载到全局状态 export function createStore({ ctx }) { return new Vuex.Store({ state: { ...state, ctx, }, getters, actions, mutations, modules: { // ... }, plugins: debug ? [createLogger()] : [], }); } [代码] 当发起异步请求时,手动带上[代码]cookie[代码],项目中使用的是[代码]Axios[代码]: [代码]// actions.js // ... const actions = { async getUserInfo({ commit, state }) { let requestParams = { params: { random: tool.createRandomString(8, true), }, headers: { 'X-Requested-With': 'XMLHttpRequest', }, }; // 手动带上cookie if (state.ctx.request.headers.cookie) { requestParams.headers.Cookie = state.ctx.request.headers.cookie; } // ... let userInfoRes = await Axios.get(`${requestUrlOrigin}${url.GET_USERINFO}`, requestParams); commit(globalTypes.SET_USERINFO, { userInfo: userInfoRes.data, }); } }; // ... [代码] 接口请求时报[代码]connect ECONNREFUSED 127.0.0.1:80[代码]的问题 原因是改造之前,使用客户端渲染时,使用了[代码]devServer.proxy[代码]代理配置来解决跨域问题,而服务端作为代理服务器对接口发起异步请求时,不会读取对应的[代码]webpack[代码]配置,对于服务端而言会对应请求当前域下的对应[代码]path[代码]下的接口。 解决方案为去除[代码]webpack[代码]的[代码]devServer.proxy[代码]配置,对于接口请求带上对应的[代码]origin[代码]即可: [代码]let requestUrlOrigin; if (state.ctx.URL.hostname === 'localhost' || state.ctx.URL.hostname === '127.0.0.1') { // 本地开发,转到localhost:9000 requestUrlOrigin = constParams.CONST_LOCAL_WEBSERVER_ORIGIN; } else { // 测试环境、正式环境、盒子 requestUrlOrigin = state.ctx.URL.origin; } let userInfoRes = await Axios.get(`${requestUrlOrigin}${url.GET_USERINFO}`, requestParams); [代码] 对于[代码]vue-router[代码]配置项有[代码]base[代码]参数时,初始化时匹配不到对应路由的问题 在官方示例中的[代码]entry-server.js[代码]: [代码]// entry-server.js import { createApp } from './app'; export default context => { // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise, // 以便服务器能够等待所有的内容在渲染前, // 就已经准备就绪。 return new Promise((resolve, reject) => { const { app, router } = createApp(); // 设置服务器端 router 的位置 router.push(context.url); // ... }); } [代码] 原因是设置服务器端[代码]router[代码]的位置时,[代码]context.url[代码]为访问页面的[代码]url[代码],并带上了[代码]base[代码],在[代码]router.push[代码]时应该去除[代码]base[代码],如下所示: [代码]router.push(context.url.replace('/base', '')); [代码] 小结 本文为笔者通过对现有项目进行改造,给现有项目加上[代码]Vue[代码]服务端渲染的实践过程的总结。 首先阐述了什么是[代码]Vue[代码]服务端渲染,其目的、本质及原理,通过在服务端使用[代码]Vue[代码]的虚拟[代码]DOM[代码],形成初始化的[代码]HTML[代码]字符串,即应用程序的“快照”。带来极大的性能优势,包括[代码]SEO[代码]优势和首屏渲染的极速体验。之后阐述了[代码]Vue[代码]服务端渲染的基本用法,即两个入口、两个[代码]webpack[代码]配置,分别作用于客户端和服务端,分别生成[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码]作为打包结果。最后通过对现有项目的改造过程,包括对路由进行改造、数据预获取和状态初始化,并解释了在[代码]Vue[代码]服务端渲染项目改造过程中的常见问题,帮助我们进行现有项目往[代码]Vue[代码]服务端渲染的迁移。
2019-04-16 - 微信开放平台-代公众号发起网页授权教程
[图片] 回调地址需要配置公众号开发域名内,多个以英文;隔开。切忌不要配置在登录授权的发起页域名内,否则会报错redirect_uri 10003。 回调地址需要配置公众号开发域名内,多个以英文;隔开。切忌不要配置在登录授权的发起页域名内,否则会报错redirect_uri 10003。 回调地址需要配置公众号开发域名内,多个以英文;隔开。切忌不要配置在登录授权的发起页域名内,否则会报错redirect_uri 10003。
2019-04-11 - 小程序广告投放的3点建议
“ 01、明确广告投放核心目的 在小程序广告投放中,不同广告位都具有不同的流量优势,建议广告主根据不同的投放目标进行组合投放。例如,要想激活新客,可提供有吸引力的素材,重点投放朋友圈或小游戏激励式视频广告,加深对用户的触达;要想提高留存,可重点投放公众号底部及文中广告和小程序Banner广告,降低唤起成本,精准转化用户。 02、多维度洞察目标人群 广告主可以从粉丝用户、广告互动用户、活跃用户等不同维度,洞察人群需求,全方位触达潜在用户。清晰、精细的目标人群画像有助于广告平台帮助其找到最合适的媒体、广告位以及投放时段,进行高效的流量匹配和沟通,精准地实现推广目标,间接的控制成本。 03、据投放数据持续调整 很多广告主有一个潜意识里的误区,认为获取了精准的流量就万无一失了。事实上,广告投放和流量交易的过程中广告主需要不停地根据投放数据持续调整自己的产品和广告素材,去配合广告平台优化投放策略,广告投放不是一蹴而就的事情。且投放过后,要做好复盘,以在每次投放中逐步提升。 在基于社交、场景的去中心化的小程序电商模式下,商家通过广告投放获取大量精准用户,继而利用产品自身的社交裂变技巧实现用户数几何式增长,最终将用户沉淀到公众号,建立私域流量,通过内容或营销不断的促活,形成获客到转化到沉淀再到复购的闭环。 中国的电商格局每过四五年都会迎来大洗牌,现在的小程序流量正处于价格最低的阶段,接下来大量品牌和中心化电商平台将会往小程序迁移,以抢占这样的战略高地和先发优势。小程序背后的流量变迁随着广点通的重视、小盟广告等第三方广告平台的参与在不断加速,我相信未来小程序市场、小程序广告市场的竞争都会更加激烈,越来越多的广告主和流量主会参与进来,微信生态在下一步会展现出在广告市场的无限想象空间。 [图片]
2019-04-01 - Three.js 文字渲染那些事
THREE.js开发的应用运行在iphone5下发现有些时候会崩溃,跟了几天发现是因为Sprite太多频繁更新纹理占用显存导致的。通常解决纹理频繁更新问题就要用到one draw all方法,放到纹理上就是把所有纹理图片生成一张大图片的方式 一、阻止纹理重复上传 我们需要一张大纹理,先将所有的内容绘制在大纹理上,需要显示局部纹理的时候通过纹理坐标控制去大纹理上取图像。那么这个时候问题来了,THREE.js内部实现方式是将Texture与图片、纹理坐标绑定,即使为所有的Texture对象设置同一张图片,THREE.js仍然会将每个Texture中的图片上传给GPU。每次上传一张大纹理严重阻塞UI渲染进程。[图片] [图片] 首先要解决的是让这张大纹理值上传一次。 这个问题需要我们对THREE.js源码进行深入了解,可以看到setTexture2D函数中有一个properties变量,这个变量是一个WebGLProperties类型的变量,而该类型存储各种东西:Texture、Material、RenderTarget、Object的buffers等。我们继续深入该类的源码,发现get方法会根据对象的uuid来获取相关WebGL属性,比如gl.createTexture、gl.createBuffer创建的各种缓冲区。 [图片] 对应Texture得到的webgl属性如下,其中__webglTexture就是对应的纹理图片创建的缓冲区对象。 [图片] 那么我们可以来一个取巧的方法,将所有纹理的的uuid都设置唯一,那么THREE.js只会对第一个Texture的纹理进行上传,后面的texture对象取到的都是第一个的properties,这样就能避免纹理重复上传。 [图片] 二、建立纹理索引 我们需要自己维护一套索引关系,通过这套索引关系得到每个贴图在大纹理中纹理坐标。这里要为每一个poi记录它的起始位置和区域范围,其中要用到canvasContext.measureText来测量文本的宽度,文本高度可以直接根据fontSize取得。 [图片] 同时索引建立完毕后,需要计算每个poi区域在全局纹理中的纹理坐标范围: [图片] 要注意的是,这里纹理坐标的原点在左下方,有时候原点在左上方。建立索引代码如下 [图片] 三、局部更新 上述方案虽然能够避免频繁上传纹理,但是需要每次将需要绘制的内容准备好,当有内容需要更新时,还是需要重新上传整个全局纹理,反而使得性能下降巨大。经过查阅资料后发现webgl中有一种局部纹理更新技术,简单来说先在内存中开辟一块的纹理区域,将所有内容绘制在这张全局纹理中,每次有更新时,只需要更新它的一个局部区域即可。 但是这里要解决的问题是THREE.js并没有提供局部纹理更新的方式,也没有相应的自定义接口,那么这时候就需要我们自己来处理了。 这里自定义一个Texture的子类 [图片] 开辟一块内存区域 [图片] 在需要的时候动态更新局部纹理,其中src这里是ImageData对象[图片] 具体代码可以参考这里,我这里也是基于它来定制的。 https://github.com/spite/THREE.UpdatableTexture 原文作者通过更改THREE.js源码的方式实现,而我是直接把下面这个函数拷贝到这个子类中 [图片] 四、高清屏的大坑 现在我们的方案是,先在gpu中开辟一块全局纹理区域,然后绘制时将poi绘制到一张与全局纹理同样大小的canvas上,然后从canvas中调用createImageData来获取像素,将像素局部更新到gpu中。那么在pc上我们得到的结果很完美。 [图片] 然而放到移动端上后,我们得到的结果是:[图片] [图片] TMMD中间那块哪去了!找了大半天发现问题出现在高清屏上,挡在高清屏上绘制canvas上时,我们通常会做一些高清处理,比如四像素绘制一像素。 我们做高清处理的方式是利用radio*radio设备像素绘制一css像素,看起来是css像素的大小,但实际在浏览器内部,看起来css上一像素实际在canvas里的像素是radio * radio(radio代表window.devicePixelRatio) [图片] 但实际上在浏览器内部绘制canvas图像的单位是设备像素。那么如果我们还以上面的rectW、rectH来获取像素的话,我们得到的这部分像素并不是这个poi真正占有的像素数目。 [图片] 所以,问题就来了我们需要在gpu开辟的全局纹理的单位跟canvas中获取像素的单位要保持一致,我们统一使用设备像素。 [图片] 我们对canvas也不用使用style来设置样式宽高了。 [图片] 那么获取poi图像的真正像素范围时: [图片] 所以利用getImageData取像素时候,就要小心取到真正的像素区域,(startX * radio,startY * radio)- (poiRectW * radio, poiRectH * radio);否则某些像素就会被丢弃掉,这部分像素才是浏览器真正使用的设备像素。 现在在移动设备上能够获取正确的高清label啦! [图片] 五、局部更新引起的新问题 当全局纹理被占满时候,在继续绘制poi,这时候新的poi区域需要更新到gpu中,那么也就带来了新的问题,在gpu中的纹理还保持着之前的像素,而新的poi会覆盖这部分区域,但有时候往往会与之前的文字叠加起来,效果如下: [图片] 可以看到新更新的poi,在计算纹理坐标时候,有一部分像素包含了其他poi的像素。这个问题是因为新poi的区域刚好叠在了先前poi的边界上,那么我们只要给新的poi加一点buffer,这个buffer是白素透明区域,buffer会把之前的poi像素覆盖掉,而我们计算纹理坐标时,只取poi的边界,那么就可以解决这个问题。 [图片] 那么首先绘制的时候就要保留buffer [图片] 上传的时候使用buffer [图片] 计算纹理坐标时,排除buffer [图片] 六、局部更新带来的性能问题 根据目前的结果,局部更新能后解决crash的问题,但是带来了严重的性能开销,与同事应用局部更新提升性能的结果相反。这个问题还要继续跟踪。 目前发现问题是因为使用了getImageData来获取数据,然后传递到gpu中,非ios设备用这种方式有时候getImageData的开销特别大,而ios设备相对好一些。 测试发现非ios设备直接上传一张大纹理的效果反而比getImageData这种方式更好。但是依然不如之前上传多个canvas的性能。而在iphone5的测试机和iphone6的机器上性能比之前直接上传多个canvas的方式好一些,且没有崩溃问题。但是在岳阳的iphone6 plus 16g内存的手机上发现用具局部纹理更新性能很差,而且经常崩溃。 后来发现原因是因为,虽然getImageData在IOS上性能好过非IOS设备,但性能开销仍然比较大,所以当场景中POI很多时,仍然会引起主线程卡顿,甚至计算太密集引起浏览器崩溃。其中层尝试使用cesium方式,每个poi创建新的canvas,将canvas进行局部上传,本以为这种方式不需要getImageData会更快一些,然而实践发现每次创建canvas设置参数的过程更耗时。 最终的方案是仍然使用getImageData,但是将getImageData的过程分块处理,每50ms处理一次,分块放到场景中,这样就解决密集计算引起的崩溃问题,虽然增加了控制成本,但是能够有效解决IOS崩溃问题。有趣的是在安卓上getImageData方式开销很大,即使分块也不适合,而且安卓用一张大纹理的方式来处理,会发现很多POI绘制效果不好。 [图片] 最终方案是,IOS使用getImageData局部纹理+分块加载方式绘制POI。安卓使用POI独立创建canvas+全量加载方式。(安卓不适用分块加载,是为了尽快把所有POI呈现给用户) 七、文字黑色描边问题 这个问题自始至终困扰我好久一直没找到黑边的原因; [图片] 将原始的canvas导出后发现这是因为原始的canvas就有一层边界 [图片] 曾经怀疑是minFilter的设置不对在pc端纹理使用NEARESTFilter方式取值发现的确能够消除黑边,然而移动端仍然会出现黑边,最后使用颜色混合公式解决问题。 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); 在Three.js中需要设置SpriteMaterial的blending为CustomBlending 八、颜色混合新问题 但是使用上述方式同样引来新问题,设计反映poi的icon四周被裁切掉, [图片] 看着没问题是吧,设计同学截了图之后放大了20倍。。。。。 [图片] 刚开始我确实以为这是webgl渲染问题,后来仔细考虑了下这外圈白色的由来(遇到问题还是得静下心分析)。 原因是设置了blendFunc(SrcAlphaFactor,OneMinusSrcAlphaFactor)导致有些icon周围的像素alpha比较低 [图片] 颜色混合后增加了target的颜色分量,导致最终这些区域的颜色范围接近255,所以泛白。从而把原来图片四周有切边的问题充分暴露出 解决方法是设置alphaTest,如果原始纹理的alpha小于这个值则直接discard。最终得到的效果是: [图片][图片][图片] 九、TextureAlta问题 前面因为sprite的旋转中心只能放在sprite纹理区域的中心所以,上面做了很多冗余纹理,有很多空白区域,目前改造了Sprite加了pivot可以动态改变选中中心点,改变后IOS下纹理的使用率提升了60%,安卓下因为是单个纹理上传所以,需要保证纹理的大小是2的n次方,纹理的浪费率降低了50% 上述问题虽然解决了崩溃问题,但是实际使用中每个poi都要getImageData和texSubImage2D这个方法,造成单个poi耗时基本在25ms(iphone5 8.4.4);虽然上面使用setTimeout 50ms分块方式上传,但是如果poi过多比如1000多的停车场,这样会导致停车场数据需要50s才能完全显示出来。这次优化的方案是等待所有poi图片拿到后,绘制所有的poi把画布调用一次getImageData和一次texSubImage2D上传到gpu,同时下次更新时,只会增量一次性上传更新。 [图片] 十、Frstrum增量更新 原来是在每一级别缩放时把所有的poi都生成好,现在的做法是只生成视锥体中能看得到的poi,然后在每次OrbitControl出发change事件时根据视锥体判断poi,做去重后增量更新 [图片] 目前还是有些问题,有时候会碰到视锥体中的poi很少,可能是判断问题,后续会加入空间索引,根据索引和视锥体结合起来做增量更新 后续使用发现在停车场这种大数据的poi全部加载到地图下,使用这种方式每次都要做去重处理,性能开销很大,处理方式是使用{}做hash代替数组includes方法,结果发现性能提示很大,原来3600个节点每次去重处理在iphone 16g 10.3.3上性能基本在28帧每秒,经过优化后数据帧率达到50+(主流iPhone7fps60);iphone5 16g 8.4.1 性能在24左右优化后帧率在44+,安卓华为荣耀9优化前25帧,优化后 40+ [图片]安卓之所以不适用IOS的绘制方式,是因为这种在安卓上的绘制效果不理想,被设计挑战 安卓后面也做了一些优化,之前安卓是每次都会重新创建canvas并上传至gpu纹理中,导致使用视景体增量更新poi时,性能有所下降,后来每一层中的poi都根据icon、文字组成key缓存起来,并且缓存纹理,不但阻止canvas的重复创建,还阻止canvas重复上传至gpu纹理(three中使用同一uuid),使用该方案荣耀9的fps达到50+ 十一、text glyphs该方式还有待尝试 https://webglfundamentals.org/webgl/lessons/webgl-text-glyphs.html 十二、真正解决POI文字黑边问题 由于要做poi渐变出现效果,但是因为之前处理黑边问题用的是颜色混合的方式,所以当动态改变透明度时,受颜色混合影响往往是文字颜色先消失,剩下透明度部分还存在显示先过很差。所以要实现渐变效果,不能使用颜色混合方法,但不适用颜色混合就会有黑边问题,所以要从源头上解决黑边问题。(看到最后会发现有残影) [图片] 那么思考黑边到底是怎么产生的,这与webgl中纹理插值的颜色有关,有的设备像素取纹理时有不同的方案,但一般情况下纹理像素和设备像素都不是一一对应,所以有插值取值问题。 [图片] 这是正常情况下利用canvas绘图时背景颜色不设置,那么可以看到我们绘制出来的canvas的确有一层奇怪的黑边。当设备取到纹理中这些边界时就会产生黑边。那么就要思考怎么不让它取到这层黑边,这个问题想了好久曾经试过用opacity过滤,发现不能解决问题。 [图片] 有一天突然想到如果canvas背景为有颜色,每个设备像素都能取到颜色,那么就不会有这个问题。所以我们能否通过改一下canvas的背景颜色同时有通过透明度过滤掉不合格的像素?最终发现这个问题还真可以。 首先在绘制时将canvas背景设置为白色,但是有很低的透明度 [图片] 这时候canvas绘制出来的效果是 [图片] 可以看到已经没有黑边了,那么这时候设备像素永远不会取到黑色边界,也就彻底解决了黑边问题。 那么就可以利用tween来做动画了
2019-03-14 - 企鹅辅导课程详情页毫秒开的秘密 - PWA 直出
[图片] 天下武功,唯 (wei) 快(fu) 不(bu) 破(po)。 随着近几年的前端技术的高速发展,越来越多的团队使用 React、Vue 等 SPA 框架作为其主要的技术栈。以 React 应用为例,从性能角度,其最重要的指标可能就是首屏渲染所花费的时间了。那么今天,我们要给大家分享的一个把优化做到极致的故事。 我们的目标是让 H5 的页面也能够拥有 Native 般的体验,如果你还在寻求什么技术能够让老板虎躯一震(拯救你的KPI),那么这篇文章或许能够帮助到你。 企鹅辅导课程详情页是什么 [图片] 课程详情页是腾讯旗下 K12 在线教育产品 企鹅辅导 APP 中最重要页面之一,也是流量最大的页面之一,所以它的打开速度也是至关重要的。 这是一个使用 [代码]React[代码] 编写的 H5 页面,运行于多端,包括: [代码]企鹅辅导APP[代码]、[代码]手机 QQ[代码]、[代码]手机浏览器[代码]。 架构演变 纯异步渲染 我们知道当前主流的 SPA 的应用的默认渲染方式都是这样的: [图片] 在这种情况下,从加载页面到用户看到页面(首屏渲染所花费的时间)就是上图中灰色边框区域所包括的时间。 这是最慢的一种方式,就算 CGI 够快,最少要花费 1S 到 2S 左右的时间了。 接着我们简单优化一下: 把静态资源缓存起来,这样下次用户打开的时候就不用从网络请求了。 第 ④ 步拉取 CGI 这个动作是否可以提前呢?我们可以在请求 HTML 之后,先通过一段 JS 脚本去请求 CGI 数据,后面第 **④ **步的时候,就可以直接拿到数据了,这就是 CGI 预加载。 怎么做到呢?我们的方案是统一封装 Request 请求工具,在用 Webpack 打包的时候,会往页面顶部注入一段 预加载 CGI 的 JS 代码,维护一个CGI 与 DATA 对应 MAP,后面发请求的时候,先去 MAP 里取值,如果有值的话直接拿出来,没有的话则发起HTTP 请求。(具体请查阅我们团队开源的 Preload 工具) [图片] 这种模式还有一些其他的优化的方法: 在 HTML 内实现 Loading 态或者骨架屏; 去掉外联 css; 使用动态 polyfill; 使用 SplitChunksPlugin 拆分公共代码; 正确地使用 Webpack 4.0 的 Tree Shaking; 使用动态 import,切分页面代码,减小首屏 JS 体积; 编译到 ES2015+,提高代码运行效率,减小体积; 使用 lazyload 和 placeholder 提升加载体验。 这种模式的优化不是我们这次讲述的重点,想了解的童鞋可以查看这篇文章。 效果如下图所示: [图片] 直出同构 在异步的模式下,除了上述优化,我们还在端内(企鹅辅导 APP、手机 QQ)内做了离线包缓存(腾讯手Q方面独立研发出来的针对手机端优化的方案,简而言之就是将静态资源缓存在手机 APP 内),经过我们的数据测试,首屏渲染大概能够达到秒开(1s左右) 的效果。 [图片] 但对有着性能极致追求的我们来说,肯定是不会满意的。 继续优化,最容易、最大众的套路肯定就是直出(服务端渲染)了。 [图片] 现在直出的方案已经有很多很多种,这里也不多做介绍了,如果您想了解更多关于服务端渲染的方案,请参考这篇文章。 直出针对首屏时间的优化效果是非常明显的,经过我们的测试,数据大概能够提升**25%**左右。 直出之后的效果如下图: [图片] 可以看到对于首屏来说,没有了**【加载中…】**的等待时间,视觉体验提升了不少。 PWA 直出 [图片] 针对上述、常见的直出应用来说,我们能够优化的点在哪里呢?让我们来详细分析一波,这也是今天我们要给大家分享的重点。 首先看看直出应用各个环节的耗时表 (本地环境 2018款 iMac): 过程名称 过程花费 Node 内 CGI 拉取 300 ms RenderToString 20 ms 网络耗时 10 ms 前端HTML渲染 30 ms 从上面的表中我们看出,直出渲染的耗时的大头还是在 CGI 接口的拉取上。 我们现在提出两个问题: CGI 接口的数据是否可以缓存 ? HTML 又是否可以缓存 ? 一、接口的动静分离 [图片] 这个页面的接口数据中,有一些数据,是实时变动的, 比如:当前还剩多少个名额、此时此刻课程的价格、用户是否购买过这个课程等。 这些数据的特性决定了这个数据接口不能够被缓存。(假设将其缓存,那么就会存在可能用户进来看到当前还剩下10个名额,其实课程已经卖光了的情况) 为了这个时间耗时的大头,我们做了CGI接口的动静分离。 将与用户态、当前时间没有关联的数据(比如[代码]课程标题[代码]、[代码]课程上课的时间[代码]、[代码]试听模块的地址[代码]等)放在一个接口(静态接口),其他变化的数据放在另一个接口(动态接口)。 那么可以使用静态的接口来做服务端渲染,好处是第一比较快(少了动态的信息,而且后台也可以做缓存),第二 Node 直出可以做缓存了。 二、直出 Redis 缓存 这样我们就可以将那部分静态的、不会经常变动的数据用来直出 HTML,然后将这个 HTML 文件缓存到 Redis 中。 客户端请求此网页,Node 端接受到请求之后,先去 Redis 里拿缓存的 HTML,如果 Redis 缓存没有命中,则拉取静态的 CGI 接口渲染出 HTML存入 Redis。 客户端拿到 HTML 之后,会立刻渲染,然后再用 JS 去请求动态的数据,渲染到相应的地方。 [图片] 做完之后我们可以看到优化效果的提升是非常非常明显的: [图片] 直接从 262ms 提升到了 16ms !(本地环境),简直飞一般的感觉,妈妈再也不用担心领导看耗时了。 三、PWA 直出缓存 关于什么是 PWA ,以及如何使用,请移步这篇文章。 做了 Node 端直出的 HTML 缓存之后,我们接着优化,接着思考,是否可以在客户端也缓存 HTML,这样连网络延时这部分消耗也省掉呢。 答案就是使用 PWA 在客户端做离线缓存,将我们直出的 HTML 缓存在客户端,每次用户请求的时候,直接从 PWA 离线缓存里取出对应的直出页面(HTML)响应给用户,响应之后紧接着请求 Node 服务更新本地的 PWA 缓存。(如下图所示) [图片] 核心代码: [代码]self.addEventListener("fetch", event => { // TODO other logic (maybe fetch filter) // core logic event.respondWith( caches.open(cacheName).then(function(cache) { return cache.match(cacheCourseUrl).then(function(response) { var fetchPromise = fetch(cacheCourseUrl).then(function( networkResponse ) { if (networkResponse.status === 200) { cache.put(cacheCourseUrl, networkResponse.clone()); } return networkResponse; }); return response || fetchPromise; }); }) ); }); [代码] 废话不多说,先看效果对比 (左 PWA 直出;右 离线包): [图片] 从上图可以看出,使用了 PWA 直出缓存之后,首屏渲染基本是毫秒开,可以说与 Native 并肩了。 经过我们的数据测试,使用 PWA 直出缓存,首屏渲染的时间最好可以到400ms左右级别: [图片] PWA 直出细节优化 一、防页面跳动 因为对接口进行了动静分离,使用静态接口直出页面,然后在客户端拉取动态数据渲染完。这就可能会导致页面的抖动(比如详情页中的试听模块,是在客户端渲染的)。 [图片] 因为高度改变了,视觉上就会出现抖动(具体可以参考上面章节直出时候的 GIF 截图)。 要去掉页面抖动的情况,就必须保证容器的高度在直出时候已经存在了。 比如这个试听模块,其实这个封面图和试听按钮是可以在服务端渲染出来的,而后面的 Video 模块则必须要在客户度渲染(腾讯云 Tcplayer)。 所以这里可以拆分成:(试听封面 + 按钮 + 时间)服务端渲染 + 底层 Video(客户端渲染)。 有些需要在客户端计算高度的容器(表现为常放在 ComponentDidMount 里计算),如果它们依赖客户端环境(比如依赖当前系统是安卓还是 IOS),就导致他们肯定不能放在服务端直接渲染出来,这又怎么办呢? 这里我们的做法,是将这些计算放在 HTML body 之前,通过内联的脚本嵌入,计算出当前环境,给 body 加上一个特定的类(class),然后在这个特定的类下面的元素,就可以通过 css 给予特定的样式。比如下面代码: [代码]/* * 因为在不同的手机 APP 环境内,页面的 padding 是不一样的。 * 我们要在页面渲染完之前加上相应的 padding */ var REGEXP_FUDAO_APP = /EducationApp/; if ( typeof navigator !== "undefined" && REGEXP_FUDAO_APP.test(navigator.userAgent) ) { if (/Android/i.test(navigator.userAgent)) { document.body.classList.add("androidFudaoApp"); } else if (/iPhone|iPad|iPod|iOS/i.test(navigator.userAgent)) { if (window.screen.width === 375 && window.screen.height === 812) { document.body.classList.add("iphoneXFudaoApp"); } else { document.body.classList.add("iosFudaoApp"); } } } [代码] [代码].androidFudaoApp .tt { padding-top: 48px; background-position-y: 84px; } .iphoneXFudaoApp .tt { padding-top: 88px; background-position-y: 124px; } .iosFudaoApp .tt { padding-top: 64px; background-position-y: 100px; } [代码] 然后把这段代码通过构建插入到页面 body 之前。 [图片] 防抖动优化效果如下 (左优化完,右未优化): [图片] 二、冷启动预加载 虽然我们做了 PWA 离线缓存,但是对于冷启动来说,客户端里面的 PWA 缓存还是没有的,这样就会导致初次点击页面,渲染速度相对慢一点。 这里我们可以在 APP 启动的时候,用一个预加载的脚本最大限度的拉取用户可能访问的页面。 核心代码如下: [代码]// 预加载页面时, PWA 预缓存课程详情页面的直出 function prefetchCache(fetchUrl) { fetch("https://you preFetch Cgi") .then(data => { return data.json(); }) .then(res => { const { courseInfo = [] } = res.result || {}; courseInfo.forEach(item => { if (item.cid) { caches.open(cacheName).then(function(cache) { fetch(`${courseURL}?course_id=${item.cid}`).then(function( networkResponse ) { if (networkResponse.status === 200) { cache.put( `${courseURL}?course_id=${item.cid}`, networkResponse.clone() ); } // return networkResponse; }); }); } }); }) .catch(err => { // To monitor err }); } [代码] PWA 直出遗留问题 一、兼容性问题 随着 PWA 技术的发展,现今大部分手机以及 PC 环境已经支持对 PWA 进行了支持。经过我们的测试发现:安卓基本上都是支持的,IOS 需要11.3以上才支持。 Service Workers 兼容性图 [图片] 具体的兼容性支持点我查看。 二、IOS 渲染问题 很多的经验告诉我们,外联的 script 标签要放在 body 的后面,因为它会阻塞页面的 DOM 渲染。 经过测试发现,IOS 的 [代码]WebView[代码] ([代码]UIWebView[代码])渲染机制并不会上述一样,而是要等到后面的 JS 执行完之后才渲染页面,如果是这样,我们的直出渲染优化就没有效果了(因为 HTML 并不在最开始渲染),这里可以使用 [代码]script[代码] 标签的 [代码]async[代码] 与 [代码]defer[代码] 属性来达到异步渲染的作用。 升级 WkWebView 之后,情况得到改善,渲染正常。 附录 参考资料 PWA 的探索与最佳实践 亿万级访问量下的前端同构直出实践 React 16 加载性能优化指南 更多基于 PWA 的性能优化实践,请查看 IMWeb 团队刘华的分享。.com/course/16777)。
2019-03-15 - PWA 实践之路
注:本文需要有一定的 PWA 基础 1. 什么是 PWA? 要知道一个东西是什么,我们通常可以从它的名字入手 因此我们看下 PWA 的全称是: Progressive Web App 回答 what 这种问题,重点在于名词,因此 PWA 是一个 APP,一个独立的、增强的、Web 实现的 APP 要达到这样的目的,PWA 提供了一系列的技术 & 标准,如下图所示: [图片] 具体每一项技术是什么就不再赘述了,感兴趣的同学自行网上搜索! 下面有一个简单的 demo 可以简单体会一下: [图片] 以后我们的 web 站点可以像 app 一样,这难道不是一个令人兴奋的事情吗? 所以 PWA 是值得我们前端开发者一直关注的技术! 按照目前的兼容性和环境来看,大家应用最多的还是 Service Worker,因此接下来我们也是把重点放在 SW 上面 那什么是 Service Worker ? 大家都知道就不卖关子了,其实就是一个 Cache 说到 Cache,就一定会想到性能优化了,请看我们的第二部分 2. 首屏优化 2.1. 静态资源优化 如何利用 Cache 来进行优化?这个基本套路应该无人不知了: [图片] 那么首次加载怎么办呢?首次加载是没有缓存资源的,所以会走到线上,所以等于没有任何优化 答案就是 Cache 的第二种常用技巧: precache(预加载) 预加载的意思就是在某个地方或特定时机预先把需要用到的资源加载并缓存 我们的做法如下图所示: [图片] 构建的时候,把整个项目用到的资源输出到一个 list 中,然后 inline 到 sw.js 里面 当 sw install 时,就会把这个 list 的资源全部请求并进行缓存 这样做的结果就是,无论用户第一次进入到我们站点的哪个页面,我们都会把整个站点所有的资源都加载回来并缓存 当用户跳转另外一个页面的时候,Cache 里面就有相应的资源了! 这是我们辅导课堂页面接入 sw 之后的首屏优化效果: [图片] 2.2. 动态数据优化 除了静态资源之外,我们还能缓存其他的内容吗? 答案肯定是可以的,我们还可以缓存 cgi 数据! [图片] 缓存 cgi 数据的流程和缓存静态资源的流程主要有2个差别,上图标红的地方: 需要添加一个开关功能,因为不是所有 cgi 都需要缓存的! 页面需要展示最新的数据,因此在返回缓存结果之后,还需要请求线上最新的数据,更新缓存,并且返回给页面展示,也就是说页面需要展示2次 页面展示2次需要考虑页面跳动的体验问题,选择权在于业务本身! 这是我们辅导上课页接入该功能后的首屏优化效果: [图片] 动态数据缓存是否有意义还需要额外的逻辑来判断,这块暂时是没有做的,后续会补上相关统计 2.3. 直出html优化 还能缓存什么?我们想到了直出的 html 这里就不展开讲了,因为我们的 jax 同学已经分享了一篇优秀的文章《企鹅辅导课程详情页毫秒开的秘密 - PWA 直出》,可以去看看哈~ 3. 替代离线包 PWA 与离线包本质上是一样的,都是离线缓存 正好,我们 PC 客户端的离线包系统年久失修,在这个契机下,我们启动了使用 PWA 替换离线包的方案! 核心流程不变,基本和缓存静态资源的流程是一致的 但是离线包系统是非常成熟的系统,要完全替换掉它还需要考虑许多方面的问题。 3.1. 更新机制 离线包有个自动更新的机制,每隔一段时间就会去请求离线包管理系统是否有更新,有的话就把最新的离线包拉下来自动更新替换,这样只需要1次跳转就能展示最新的页面。 SW 没有自动更新的逻辑,它需要在页面加载(一次跳转)之后才会去请求 sw.js,判断有变化才会进行更新,更新完了要等到下一次页面跳转(二次跳转)才能展示最新的页面。 这里有两个方案: 参考离线包的更新机制,也给 SW 实现一个自动更新的逻辑,借用 update 接口是可以做到主动去执行 SW 更新的。但是非常遗憾,我们的客户端 webkit 内核版本太低,并不支持这个接口 在第一次跳转之后更新 sw,然后检测 sw 状态,发现如果有更新,就用一定的策略来进行页面的刷新 我们使用第2个方案,部分代码如下: [图片] 在检测到 sw 更新之后,我们可以选择强刷,或者提示用户手动刷新页面,具体实现页面可以通过监听事件来处理 更多详细的实现方案可以参考这篇文章哈:How to Fix the Refresh Button When Using Service Workers 3.2. 首次打开问题 一般离线包是打进 app 的安装包一起发布的,在用户下载安装之后,离线包就已经存在于本地,因此第一次打开就能享受到离线包的缓存。 但是 sw 没有这个能力,同样我们也有两个方案: 在 app 安装的时候,添加一步,通过创建 webview 加载页面,页面执行 SW 的初始化工作,并展示相应的进度提示,在安装完成后需要把 webview 的 SW Cache 底层文件 copy 到相应的安装位置。这个方案太复杂,衡量下来没有采用。 在 app 启动时,创建一个隐藏的 webview,加载空页面去加载 SW。 我们使用第2个方案,因为我们的 app 启动会先让用户登录,如下图所示: [图片] 注:这里 app 不是移动端 app,是 pc 的客户端 app 3.3. 屏蔽机制 有时候我们不想使用离线缓存能力,比如在我们开发的时候 在离线包系统,通常会有一个开发者选项是【屏蔽离线包】 SW 也是需要这种能力的,这个方案就比较简单了,在 sw.js 的逻辑里有一个全局的开关,当开关关闭时,就不会走缓存逻辑 因此,我们可以在 dev 环境下把开关关闭即可达到屏蔽的作用 [图片] 3.4. 降级方案 当我们发布了一个错误代码的时候,我们需要快速降级容错的能力 在离线包系统里面有个过期的功能,可以把某个版本设置过期,也就是废弃掉: [图片] 我们利用之前提到的全局开关,通过一个管理接口来设置开关的起开和关闭,即可达到快速降级的目的: [图片] 整个流程大致是这样: 发布了错误代码,并且用户本地 sw 已经更新缓存了错误代码 在管理端关闭使用缓存开关,让用户走线上 快速修复代码并发布,到这里页面就已经恢复正常 在管理端开启使用缓存开关,恢复 SW 功能 请求管理接口是轮询的,这里后续有计划会改成 push,这样会更加及时,当然还要详细评估方案之后才能落实。 4. 如何方便接入? 我们把上述功能集成到了一个 webpack 插件当中,在构建的时候就自动输出 sw.js 并把相关内容注入到 html 文件中,该插件正准备开源哈~ 5. 未来 未来对于 PWA 还能做些什么?笔者以为有以下 2 个方面 5.1. 业务深耕 目前通用的能力已经基本挖掘完成,但是 SW 因为它独特的特性,它能做的事情太多了,但是具体要不要这么做也是因业务而异,而且这些内容可能会很复杂,所以我称为业务深耕。 SW 什么特性?请看下面 2 张图就可以理解了 [图片] [图片] 这种架构相信已经能够看出来了,没错,SW 有间件(层)的特性,那它能做的东西就太多了(虽然 SW 是用户端本地中间层) 简单举几个例子: server 负载控制:当发现 server 端高负载时,SW 可以丢弃请求,或者缓存请求,合并/延迟发送。 预请求:SW 能预缓存的资源是可以构建出来的资源,但是我们还有许多资源是不能在构建阶段知道的,比如图片,第三方资源等,SW 在返回资源请求(比如HTML、cgi 数据)之后,可以扫描资源里面的内容,如果发现包含了其他资源的 url,那说明页面很有可能待会就会请求这些资源,这时 SW 可以提前去请求这些资源;再比如翻页的数据 缓存自动更新:通过与 server 建立联系,当数据有变化时,server 直接把最新的数据 push 到 SW 进行缓存的更新 5.2. 关注 PWA 回到最开始,PWA 是一项令人兴奋的技术,但是浏览器兼容有限,因此期待并关注 PWA 技术的发展是很有必要的! 当然,能推动就更好了!比如推动我们的 X5 内核尽快支持新特性。 关注我们 IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。 我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂 及 企鹅辅导 两大产品。 社区官网: http://imweb.io/ 加入我们: https://hr.tencent.com/position_detail.php?id=45616 [图片] 扫码关注 IMWeb前端社区 公众号,获取最新前端好文 微博、掘金、Github、知乎可搜索 IMWeb 或 IMWeb团队 关注我们。
2019-03-12 - 前端加载优化及实践
大家都知道产品体验的重要性,而其中最重要的就是加载速度,一个产品如果打开都很慢,可能也就没有后面更多的事情了。这篇文章是我最近项目中的一些加载优化总结,欢迎大家一起讨论交流。 内容包括: 性能指标及数据采集 性能分析方法 性能优化方法 性能优化具体实践 第一部分:性能指标及数据采集 要优化性能首先需要有一套用来评估性能的指标,这套指标应该是是可度量、可线上精确采集分析的。现在来一起看看如何选择性能指标吧。 1. 性能指标 加载的过程是一个用户的感知变化的过程。所以我们的页面性能指标也是要以用户感知为中心的。下面是google定义了几个以用户感知为中心的性能指标。 1.1 以用户感知为中心的性能指标 首先确定页面视觉的变化传递给用户的感知变化关键点: 感知点 说明 发生了吗? 浏览是否成功。 有用了吗? 是否有足够的内容呈现给用户。 可用了吗? 用户是否可以和页面交互了。 好用吗? 用户和应用交互是否流畅自然。 我这里讲的是加载优化,所以第四点暂时不讨论。下面是感知点相关的性能指标。 First paint(FP) and first contentful paint(FCP) FP: Webview跳转到应用的首次渲染时间。 FCP:Webview首次渲染内容的时间:文本,图像(包括背景图像),非白色画布或SVG。这是用户第一次消费内容的时间。 Chrome支持用Paint Timing API获取这两个值: [代码] performance.getEntriesByType("paint") [代码] First meaningful paint(FMP) 首次绘制有效内容的时间,用来表明这个应用是否绘制了有效内容。比如天气应用可以看到天气了,商品列表可以看到商品了。 Time to Interactive(TTI) 应用可交互时间,这时应用渲染完成且可以响应用户输入的时间。这种情况下JS已经加载完成且主线程处于空闲状态。 Speed index 速度指标:代表填充页面内容的速度。要想降低速度指标分数,您需要让加载速度从视觉上显得更快,也就是渐进式展示。 上面指标对应的感知点如下: 感知点 说明 发生了吗? FP/FCP 有用了吗? FMP 可用了吗? TTI Speed index是个整体效果指标所以没有对应上面的任何一个,但也同时对应任何一个。 对于实际项目中我们选取指标要便于采集,下面是针对我的实际项目(APP内的单页面应用)选取的性能指标。 1.2 实际项目选取的性能指标 Webview加载时间 反应Webview性能。这样就可以更真实的知道我们应用的加载情况。 页面下载时间 反应浏览成功时间。 应用启动时间 反应应用启动完成时间,这个时候页面初始化完成,是JS首次执行完成的时间,应用所需异步请求都已经发出去了。 首次有效绘制内容时间 已经有足够的内容呈现给用户,是首屏所需重要接口返回且DOM渲染完成的时间,这个时间由程序员自行判断。 应用加载完成时间 应用完整的呈现给了用户,这个时候页面中所有资源都已经下载好,包括图片等资源。 这里我们的性能指标确定了,下面看看这些数据怎么采集吧。 2. 数据采集 performance.timing为我们提供页面加载每个过程的精确时间,如下图: [图片] 是不是很完美,这足够了?还不够,我们还需要加上原生APP为我们提供的点击我们应用的时间和我们自己确定的FMP才够完美。 下面是每个指标的获取方法: 公用代码部分 [代码]let performance = window.performance || window.msPerformance || window.webkitPerformance; if (performance && performance.timing) { let t = performance.timing; let navigationStart = t.navigationStart; //跳转开始时间 let enterTime = ""; //app提供的用户点击应用的时间,需要和app沟通传递方式 //... 性能指标部分 } [代码] Webview加载时间 [代码] let webviewLoaded = navigationStart - enterTime; [代码] 注意:enterTime应该是客户端ms时间戳,不是服务器时间。 页面下载时间 [代码] let pageDownLoadedTime = t.responseEnd - navigationStart; [代码] 应用启动时间 [代码]let appStartTime = t.domContentLoadedEventStart - navigationStart; [代码] 首次有效绘制内容时间 这里我们需要在有效绘制后调用 [代码]window._fmpTime = +(new Date())[代码]获取当前时间戳。 [代码]let fmpTime = window._fmpTime - navigationStart; [代码] 应用加载完成时间 [代码]let domCompleteTime = t.domComplete - navigationStart; [代码] 最后在document load以后使用上面代码就可以收集到性能数据了,然后就可以上报给后台了。 [代码]if (document.readyState == 'complete') { _report(); } else { window.addEventListener("load", _report, false); } [代码] 这样就封装了一个简单性能数据采集上报组件,这是非常通用的可以用在类似项目中使用只要按照标准提供enterTime和window._fmpTime就可以。 3. 数据分析 有了上面的原始数据,我们需要一些统计方法来观察性能效果和变化趋势,所以我们选取下面一些统计指标。 平均值 注意在平均值计算的时候要设置一个取值范围比如:0~10s以防脏数据污染。 平均值的趋势用折线图展示: [图片] 分布占比 可以清晰的看到用户访问时间的分布,这样你就可以知道有多少用户是秒开的了。 分布占比可以使用折线图、堆积图、饼状图展示: [图片] [图片] [图片] 第二部分:性能分析方法 上面有了性能指标和性能数据,现在我们来学习一下性能分析的一些方法,这样我们才能知道性能到底哪里不行、为什么不行。 1. 影响性能的外部因素 分析性能最重要的一点要确定外部因素。经常会有这种情况,有人反应页面打开速度很慢,而你打开速度很快,其实可能并不是页面性能不好,只是外部因素不同而已。 所以做好性能优化不能只考虑外部因素好的情况,也要让用户能在恶劣条件(如弱网络情况)下也有满足预期的表现。下面看看影响性能的外部因素主要有哪些。 1.1 网络 网络可以说是最影响页面性能最重要的外部因素了,网络的主要指标有: 带宽:表示通信线路传送数据的能力,即在单位时间内通过网络中某一点的最高数据率,单位有bps(b/s)、Kbps(kb/s)、Mbps(mb/s)等。常说的百兆带宽100M就是100Mbps,理论下载最大速度12.5MB/s。 时延:Delay,指数据从网络的一端传送到另一端所需的时间,反应的网络畅通程度。 往返时间RTT:Round-Trip Time,是指从发送端发送数据到接收端接受到确认的总时间。我们经常用的ping命令就是用这个指标表明我们和目标主机的网络顺畅程度。比如我们要对比几个翻墙代理哪里个好,我们就可以ping一下,看看这几个代理哪个RTT低来作出选择。 [图片] 这三个主要指标中后面两个类似,在Chrome中模拟网络主要用设置带宽和网络延迟(往返时间RTT出现最小延迟)来模拟网络。我们电脑一般用的是WI-FI(百兆),那么我们模拟网络,主要模拟常见3G(1兆)、4G(10兆)网络就好,这样我们就覆盖了三个级别的网络情况了。 可以在Chrome的NetWork面板直接选取Chrome模拟好的网络,这个项目network-emulation-conditions中有默认模拟网络的速度。 [图片] 如果默认不满足,你也可以自己配置网络参数,在设置面板的Throttling。 [图片] 上面设置的3G接近100KB/s,4G 0.5MB/s。你可以根据自己的需要来调整这个值,这两个值的差异应该能很好两种不同的网络情况了。设置模拟网络只要能覆盖不同的带宽情况就好,也不用那么真实因为真实情况很复杂。网络部分就介绍完了,接着看其他因素。 1.2 用户机器性能 经常会有这种情况,一个应用在别人手机上打开速度那么快、那么流畅,为啥到我这里就不行了呢?原因很简单人家手机好,自然有更好的配置、更多的资源让程序运行的更快。 Chrome现在非常强大你可以通过performance面板来模拟cpu性能。也可以让你看到应用在低性能机器上的表现。 [图片] 1.3 用户访问次:首次访问、2次访问、发版本访问 用户访问次数也是分析性能的重要外部因素,当用户第一次访问要请求所有资源,后面在访问因为有些资源缓存了访问速度也会不同。当我们开发者又发版本,会更新部分资源,这样访问速度又会跟着变。因为缓存的效果存在,所以这三种情况要分开分析。同时也要注意我们是否要支持用户离线访问。 通过在Chrome中的Network面板中选中Disable cache就可以强制不缓存了,来模拟首次访问。 [图片] 1.4 因素对选取 上面的外部因素虽然只有3种但相乘也有不少情况,为了简化我们性能分析,要选取代表性的因素去分析我们的性能。下面是指导因素对: 网络:WIFI 3G 4G 用户访问状态:首次 2次 这样有6种情况不算特别多,也能很好反应我们应用在不同情况下的性能。 2. devtools具体分析性能 通过devtools可以观察在不同外部因素下代码具体加载执行情况,这个工具是我们性能分析中最重要的工具,加载优化这里我们主要关注两个面板:Network、Performance。 先看Network面板的列表页: [图片] 这是网络请求的列表,右击表头可以增删属性列,根据自己需要作出调整。 下面我介绍网络列表中的几个重点属性: Protocol:网络协议,h2说明你的请求是http2协议的了。 Initiator:可以查到这个资源是哪里引用的。 Status:网络状态码。 Waterfall:资源加载瀑布流。 下面在看看Network面板中单个请求的详情页: [图片] 这里可以看到具体的请求情况,Timing面板是用来观察这次网络的请求时间占用的具体情况,对我们性能分析非常重要。具体每个时间段介绍可以点击Explanation。 虽然Network面板可以让我看到了网络请求的整体和单个请求的具体情况,但Network面板整体请求情况看着并不友好,而且也只有加载情况没有浏览器线程的执行情况。下面看看强大的Performance面板的吧。 [图片] 这里可以清晰看到浏览器如何加载资源如何解析html、解析css、执行js和渲染绘制的。 Performance简直太强大了,所以请你务必要掌握它的使用,这里篇幅有限,只能介绍了个大概,建议到google网站仔细学习一下。 3. Lighthouse整体分析性能 使用Lighthouse可以对应用做整体性能分析评分,并且会给我们专业的指导建议。我们可以安装Lighthouse插件或者安装Lighthouse npm包来使用它。 检测结果中可以看到很多性能指标的分值和建议。你也可以去测试下你的应用表现。 4. 线上用户统计分析性能 虽然使用devtools和Lighthouse可以知道页面的性能情况,但我们还要观察用户的真实访问情况,这才能真实反映我们应用的性能。线上数据采集分析,第一步部分已经介绍过了,这里就不在多说了。优化完看看自己对线上数据到底造成了什么影响。 上面介绍了性能分析的方法,可以很好帮你去分析性能,有了性能分析的基础,下面我们在来看看怎么做性能优化吧。 第三部分:性能优化方法 1. 微观:优化单次网络请求时间 在性能分析知道Network面板可以看到单次网络请求的详情 [图片] 从图可以看出请求包括:DNS时间、TCP时间、SSL时间(https)、TTFB时间(服务器处理时间)、ContentLoaded内容下载时间,所以有下面公式: [代码]requestTime = DNS + TCP + SSL+ TTFB +ContentLoaded [代码] 所以只要我们降低这里面任意一个值就可以降低单次网络请求的时间了。 2. 宏观:优化整体加载过程 加载过程的优化就是不断让第一部分的性能指标感知点提前的过程。通过关键路径优化、渐进式展示、内容效率优化手段,来优化资源调度。 2.1 加载过程 在介绍页面加载过程,先看看渲染绘制过程: [图片] Javascript:操作DOM和CSSOM。 样式计算:根据选择器应用规则并计算每个元素的最终样式。 布局:浏览器计算它要占据的空间大小及其在屏幕的位置。 绘制:绘制是填充像素的过程。 合成。由于页面的各部分可能被绘制到多层,合成是将他们按正确顺序绘制到屏幕上,正确渲染页面。 渲染其实是很复杂的过程这里只简单了解一下,想深入了解可以看看这篇文章。 了解了渲染绘制过程,在学习加载过程的时候就可以把它当作黑盒了,黑盒只包括渲染过程从样式计算开始,因为上面的Javascript主要是用来输入DOM、CSSOM。 浏览器加载过程: Webview加载 下载HTML 解析HTML:根据资源优先级加载资源并构建DOM树 遇到加载同步JS资源暂停DOM构建,等待CSSOM树构建 CSS返回构建CSSOM树 用已经构建的DOM、CSSOM树进行渲染绘制 JS返回执行继续构建DOM树,进行渲染绘制 当HTML中的JS执行完成,DOM树第一次完整构建完成触发:domContentLoaded 当所有异步接口返回后渲染制完成,并且外部加载完成触发:onload 注意点: CSSOM未构建好页面不会进行任何渲染 脚本在文档的何处插入,就在何处执行 脚本会阻塞DOM构建 脚本执行要等待CSSOM构建完成后执行 下面看看如何在加载过程提前感知点。 2.2 优化关键路径 把关键路径定义为:从页面请求到应用启动完成这个过程,也就是到JS执行完domContentLoaded触发的过程。 主要指标有: 关键资源: 影响应用启动完成的资源。 关键资源的数量:这个过程中加载的资源数据。 关键路径长度:关键资源请求的串行长度。 关键字节的数量:关键资源大小总和。 [图片] 上图关键资源有:html、css、3个js。关键资源数量:5个。关键字节的数量:5个资源的总大小。关键路径长度:2,html+剩余其他资源。 关键优化路径优化,就是要降低关键路径长度、关键字节的数量,在http1时代还要降低关键资源的数量,现在http2资源数不用关心。 2.3 优化内容效率 主要是关注的应用加载完成这个时间点,由首页加载完成所需的资源量决定。我们要尽量减少加载资源的大小,避免不必要加载的资源,比如做一些图片压缩懒加载尽快让应用加载完成。 主要指标有: 应用加载完成字节数:应用加载完成,所需的资源大小。 这个指标可以从Chrome上观察到,不过要剔除prefetch的资源。这个指标一般不太稳定,因为页面展示的内容不太相同,所以最好在相同内容相同情况下对比。 2.4 渐进式展示 从上面的加载过程中,可以知道渲染是多次的。那样我们可以先让用户看到一个Loading提示、先展示首屏内容。Loading主要优化的是FP/FCP这两个指标,先展示首屏主要是优化FMP。 3. 缓存:优化多次访问 缓存重点强调的是二次访问、发版访问、离线访问情况下的优化。 通过缓存有效减少二次访问、发版访问所要加载资源,甚至可以让应用支持离线访问,而且是对弱网络环境是最有效的手段,一定要善于使用缓存这是你性能优化的利器。 4. 优化手段 优化手段我归纳为5类:small(更小)、pre(更早)、delay(更晚)、concurrent(并发)、cache(缓存)。性能优化就是将这5种手段应用于上面的优化点:网络请求优化、关键路径优化、内容效率优化、多次访问优化。 5. 构建自己可动态改变的优化方法表和检查表 Checklist包括两部分,一个优化方法表,另外一个优化方法检查表。优化方法表是让我们对我们的性能优化方法有个评估和认识,优化方法检查表的好处是,可以清晰的知道你的项目用了哪些优化方法,还有哪些可以尝试做进一步优化,同时作为一个新项目的指导。 优化名:优化方法的名字。 优化介绍:对优化方法做简单的介绍。 优化点:网络请求优化、关键路径优化、内容效率优化、多次访问优化。 优化手段:small、pre、delay、concurrent、cache。 本地效果:选取合适的因素对,进行效果分析,确定预期作用大小。 线上效果:线上效果对比,确定这个优化方案的有效性及实际作用大小。 这样我们就能大概了解了这个效果的好处。我们新引入了一种优化方法都要按这张表的方法进行操作。 优化方法表: 名称 内容 优化名 JS压缩 优化介绍 压缩JS 优化点 关键路径优化 优化手段 small 本地效果 具体本地效果对比 线上效果 线上数据效果 上面是以JS压缩为例的优化方法表。 优化方法检查表: 分类 优化点 是否使用 不适用 问题说明 small JS压缩 √ pre preload/prefetch √ 不需要 通过这张表就能看出我们使用了哪些方法,还有哪些没使用,哪些方法不适用我们。可以很方便的应用于任何一个新项目。 第四部分:性能优化具体实践 现在就看看我在项目中的具体实践吧,项目中使用的技术栈是:Webpack3+Babel7+Vue2,下面我按照优化手段介绍: 1. small(更小) scope-hoisting scope-hoisting(作用域提升):Webpack分析出模块之间的依赖关系,把可以合并到一起模块合并到一起,但不造成冗余,因此只有被一个地方引用的代码可以合并到一起。这样做函数声明会变少,可以让代码更小、执行更快。 这个功能从Webpack3开始引入,依赖于ES2015模块的静态分析,所以要把Babel的preset要设置成[代码]"modules": false[代码]: [代码] ... [ "@babel/preset-env", { "modules": false ... [代码] Webpack3要引入ModuleConcatenationPlugin插件,Webpack4 product模式已经预置该插件: [代码]... new webpack.optimize.ModuleConcatenationPlugin(), ... [代码] [图片] 如上图,不压缩的JS中可以文件中看到CONCATENATED MODULE这就说明生效了。 tree-shaking 摇树:通常用于描述移除JavaScript上下文中的未引用代码,在webpack2中开始内置。依赖于ES2105模块的静态分析,所以我们使用babel同样要设置成 [代码]"modules": false[代码]。 [图片] 如上图,不压缩的JS中可以文件中看到unused harmony这就说明摇树成功了。 code-splitting(按需加载) 代码分片,将代码分离到不同的js中,进行并行加载和按需加载。 代码分片主要有两种: 按需加载:动态导入 vendor提取:业务代码和公共库分离 这里只介绍按需加载部分,动态导入Webpack提供了两个类似的技术。1. Webpack特定的动态导入require.ensure。2.ECMAScript提案[代码]import()[代码]。这里我只介绍我使用的[代码]import()[代码]这种方法。因为是推荐方法。 代码如下: Babel配置支持动态导入语法: [代码]... "@babel/plugin-syntax-dynamic-import", ... [代码] 代码中使用: [代码]... if(isDevtools()){ import(/* webpackChunkName: "devtools" */'./comm/devtools').then((devtools)=>{ let initDevtools = devtools.default; initDevtools(); }); } ... [代码] polyfill按需加载 我们代码是ES2015以上版本的要真正能在浏览器上能使用要通过babel进行编译转化,还要使用polyfill来支持新的对象方法,如:Promise、Array.from等。对于不同环境来说需要polyfill的对象方法是不一样的,所以到了Babel7支持了按需加载polyfill。 下面是我项目中的配置,看完以后我会介绍一下几个关键点: [代码]module.exports = function (api) { api.cache(true); const sourceType = "unambiguous"; const presets = [ [ "@babel/preset-env", { "modules": false, "useBuiltIns": "usage", // "debug": true, "targets": { "browsers": ["Android >= 4.0", "ios >= 8"] } } ] ]; const plugins= [ "@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-strict-mode", "@babel/plugin-proposal-object-rest-spread", [ "@babel/plugin-transform-runtime", { "corejs": false, "helpers": true, "regenerator": false, "useESModules": false } ] ]; return { sourceType, presets, plugins } } [代码] @babel/preset-env preset是预置的语法转化插件的集合。原来有很多preset如:@babel/preset-es2015。直到出现了@babel/preset-env,它可以根据目标环境来动态的选择语法转化插件和polyfill,统一了preset众多的局面。 [代码]targets[代码]:是我们用来设置环境的,我的应用支持移动端所以设置了上面那样,这样就可以只加载这个环境需要的插件了。如果不设置[代码]targets[代码]通过@babel/preset-env引入的插件是 @babel/preset-es2015、@babel/preset-es2016和@babel/preset-es2017插件的集合。 [代码]"useBuiltIns": "usage"[代码]:将useBuiltIns设置为usage就会根据执行环境和代码按需加载polyfill。 @babel/plugin-transform-runtime 和polyfill不同,@babel/plugin-transform-runtime可以在不污染全局变量的情况下,使用新的对象和方法,并且可以移除内联的Babel语法转化时候的辅助函数。 我们这里只用它来移除辅助函数,不需要它来帮我处理其他对象方法,因为我们在开发应用不是做组件不怕全局污染。 sourceType:“unambiguous” 一个文件混用了ES2015模块导入导出和CJS模块导入导出。需要设置[代码]sourceType:"unambiguous"[代码],需要让babel自己猜测类型。如果你的代码都很合规不用加这个的。 压缩:js、css js、css压缩应该最基本的了。我在项目中使用的是[代码]UglifyJsPlugin[代码]和[代码]optimize-css-assets-webpack-plugin[代码],这里不做过多介绍。 压缩图片 通过对图片压缩来进行内容效率优化,可以极大的提前应用加载完成时间,我在项目中做了下面两件事。 广告图片,限制大小50K以内。原来基本会上传超过100K的广告图。 项目中图片使用的[代码]img-loader[代码]对图片进行压缩。 HTTP2支持,去掉css中base64图片 先看看HTTP1.1中的问题: 同一域名浏览器做了TCP连接数的限制,如:Chrome中只能有6个。 一个TCP连接只能同时处理一个请求响应。 在看看HTTP2的优势: 二进制分帧:HTTP2的性能增强的核心在于新的二进制分帧层。帧是最小传输单位,帧组成消息,数据以消息形式发送。 多路复用:所有请求在一个连接上完成,可以支持多数据流混合传输,在接收端拼接。 头部压缩:使用HPACK对头部压缩,网络中可以传递更少的数据。 服务端推送:服务端可以主动向客户端推送资源。 有了HTTP2我们在也不用担心资源数量,不用在考虑减少请求了。像:base64图片打到css、合并js、域名分片、精灵图都不要去做了。 这里我把原来base64压缩图片从css中去除了。 2. pre(更早) preload prefetch preload:将资源加载和执行分离,你可以根据你的需要指定要强制加载的资源,比如后面css要用到一个字体文件就可以在preload中指定加载,这样提高了页面展示效果。建议把首页展示必须的资源指定到preload中。 prefetch:用来告诉浏览器我将来会用到什么资源,这样浏览器会在空闲的时候加载。比如我在列表页将详情页js设置成prefetch,这样在进入详情页的时候速度就会快很多,因为我提前加载好了。 这里我用的是来使用[代码]preload-webpack-plugin[代码]preload和prefetch的。 代码: [代码]... const PreloadWebpackPlugin = require('preload-webpack-plugin'); ... new PreloadWebpackPlugin({ rel: 'prefetch', include: ['devtools','detail','VideoPlayer'] }), ... [代码] dns-prefetch preconnect dns-prefetch:在页面中请求该域名下资源前提前进行dns解析。preconnect:比dns-prefetch更近一步连TCP和SSL都为我们处理好了。 使用注意点:1. 考虑到兼容性问题,我们对一个域名两个都设置 2. 对于应用中不一定会使用的域名我们设置dns-prefetch就好以防占用资源。 代码如下: [代码]... <link rel="preconnect" href="//game.gtimg.cn"> ... <link rel="dns-prefetch" href="//game.gtimg.cn"> ... [代码] 3. delay(更晚) lazyload 对图片进行懒加载,我使用的是[代码]vue-lazyload[代码]。 代码如下: [代码]... import VueLazyload from 'vue-lazyload' ... Vue.use(VueLazyload, { preLoad: 1.3, error: '...', loading: '...', attempt: 1 }); ... <div class='v-fullpage' v-lazy:background-image="item.roomPic" :key="item.roomPic"></div> ... [代码] 这里的:key特别注意,如果你的列表数据是动态变化的一定要设置,否则图片是最开始一次的。 code-splitting(按需加载) code-splitting(按需加载)前面已经介绍过这里只是强调下它的delay作用,不使用的部分先不加载。 4. concurrent(并发) HTTP2 HTTP2前面已经应用在了css体积减少,这里主要强调它的多路复用。需要大家看看自己的项目是否升级到HTTP2,是否所有资源都是HTTP2的,如果不是的,需要推进升级。 code-splitting(vendor提取) vendor提取是把业务代码和公共库分离并发加载,这样有两个好处: 下次发版本这部分不用在加载(缓存的作用)。 JS并发加载:让先到并在前面的部分先编译执行,让加载和执行并发。 Webpack配置: [代码] ... entry:{ "bundle":["./src/index.js"], "vendor":["vue","vue-router","vuex","url","fastclick","axios","qs","vue-lazyload"] }, ... new webpack.optimize.CommonsChunkPlugin({ name: "vendor", minChunks: Infinity }), new webpack.optimize.CommonsChunkPlugin({ name: 'manifest' }), ... [代码] 5. cache(缓存) HTTP缓存 HTTP缓存对我们来说是非常有用的。 下面介绍下HTTP缓存的重点: Last-Modified/ETag:用来让服务器判断文件是否过期。 Cache-Control:用来控制缓存行为。 max-age: 当请求头设置max-age=deta-time,如果上次请求和这次请求时间小于deta-time服务端直接返回304。当响应头设置max-age=deta-time,客户端在小于deta-time使用客户端缓存。 强制缓存:这主要把不经常变化的文件设置强制缓存,这样就不需要在发起HTTP请求了。通过设置响应头Cache-Control的max-age设置。 如果像缓存很久设置一个很大的值,如果不想缓存设置成:Cache-Control:no-cahce。 协商缓存:如果没有走强制缓存就要走协商缓存,服务器根据Last-Modified/ETag来判断文件是否变动,如果没变动就直接返回304。 这里我们做的就是让运维调整资源的强制缓存时间,前端在结合文件hash命名就可以进行资源更新了。 ServiceWorker ServiceWorker是Web应用和浏览器之间的代理服务器,可以用来拦截网络来进行资源缓存、离线体验,还可以进行推送通知和后台同步。功能非常强大,我们这里使用的是资源缓存功能,看看和HTTP缓存比有什么优势: 功能多:支持离线访问、资源缓存、推送通知、后台同步。 控制力更强:缓存操作+络拦截功能都由开发者控制,可以做出很多你想做的事情比如动态缓存。 仅HTTPS下可用,更安全。 看看我在项目中的使用: js使用HTTP缓存和ServiceWorker双重缓存在cacheid变化后依然可以缓存。 不得对service-worker.js缓存,因为我们要用这个更新应用。在Chrome中看到请求的cache-control被默认设置了no-cache。 我们项目中使是Google的Workbox,Webpack中插件是 workbox-webpack-plugin。 [代码]... const WorkboxPlugin = require('workbox-webpack-plugin'); ... new WorkboxPlugin.GenerateSW({ cacheId: 'sw-wzzs-v1', // 缓存id skipWaiting: true, clientsClaim: true, swDest: './html/service-worker.js', include: [/\.js(.*)$/,/\.css$/], importsDirectory:'./swmainfest', importWorkboxFrom: 'local', ignoreUrlParametersMatching: [/./] }), ... [代码] localStorage localStorage项目中主要做接口数据缓存。通常localStorage是没有缓存时间的我们将其封装成了有时间的缓存,并且在应用启动的时候对过期的缓存清理。 code-splitting(vendor提取) 这里在提vendor提取主要是说明它发版本时候的缓存价值,前面介绍过了。 6. 整体优化效果评价 经过上面的优化,看看效果提升吧。 主要增长点来源: 关键路径资源:698.6K降低到538.6K降低22.9% 内容效率提升:广告图由原来的基本100K以上降低到现在50K以下,页面内图片全部走强制缓存。 缓存加快多次访问速度:js+css强制缓存加ServiceWorker。 线上数据效果: 页面下载时间: 平均值下降:25.74%左右 应用启动完成时间: 平均值下降:33.45%左右 秒开占比提高:23.42%左右 应用加载完成时间: 平均值下降:48.02%左右 第六部分:总结 以上就是我在加载优化方面的一些总结,希望对您有所帮助,个人理解有限,欢迎一起讨论交流。
2019-03-11 - 底部输入框获取焦点上推页面
1.首先在wxml的输入框里面添加adjust-position="{{false}}" 2.监听focus事件,通过获取e.detail.height(即弹出的软键盘的高度), 把input的输入框的bottom=e.detail.height * 2 + 'rpx';还须把content 的内容高度减去键盘的高度(值须setData下)。 3.监听失焦(blur)事件,在该方法里,把input输入框的bottom重置为0; 且内容高度为原本的内容高度即可(值须setData下)。 以上三步可完美解决
2018-08-07