- 谜之wxs,uni-app如何用它大幅提升性能
小程序里有几个谜一样的存在,微信的、支付宝的、百度的。 很多开发者都不明白为什么要造这种语言脚本的轮子出来,甚至很多开发者根本不知道它们的存在。 其实几大小程序平台创造它们,都是为了解决性能问题,但不得不吐槽下,设计的实在是很难用,文档也语焉不详。 uni-app支持将WXS、SJS、Filter编译到这3家小程序平台,同时还在App和H5实现了WXS的解析。为什么做这些事?也是为了性能。 uni-ui库新版中的swiperaction组件,就是列表项向左滑动时拉出几个挤压式联动的菜单按钮,这种流畅的跟手动画,正是借助于WXS机制实现的。 微信为何要创造WXS WXS(WeiXin Script)是微信创造的一套脚本语言,它的官方说法是:“WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致”。 那微信为何要脱离 JavaScript ,单独创造一套语言呢?这要从微信小程序的底层逻辑(运行环境)讲起。 小程序的运行环境分为逻辑层和视图层,分别由2个线程管理,其中: · WXML 模板和 WXSS 样式工作在视图层,界面使用 WebView 进行渲染 · JavaScript代码工作在逻辑层,运行在JsCore或v8里 小程序在视图层与逻辑层两个线程间提供了数据传输和事件系统。这样的分离设计,带来了显而易见的好处: · 逻辑和视图分离,即使业务逻辑计算非常繁忙,也不会阻塞渲染和用户在视图层上的交互 但同时也带来了明显的坏处: · 视图层(webview)中不能运行JS,而逻辑层JS又无法直接修改页面DOM,数据更新及事件系统只能靠线程间通讯,但跨线程通信的成本极高,特别是需要频繁通信的场景 什么是需要频繁通讯的场景?最典型的例子就是用户持续交互的情况,比如触摸、滚动等。我们以侧滑菜单为例,假设在页面上滑动A元素,要求B元素跟随移动,一次滑动操作(touchmove)的响应过程如下: touchmove 事件从视图层(Webview)传递到逻辑层,中间会由微信客户端(Native)做中转 谜之wxs,uni-app如何用它大幅提升性能 2. 逻辑层处理 touchmove 事件,计算需移动的位置,然后再通过 setData 传递到视图层,中间同样会由微信客户端(Native)做中转 谜之wxs,uni-app如何用它大幅提升性能 一次 touchmove 的响应需要经过 视图层、Native、逻辑层三者之间2个完整来回的通信,通信的耗时开销较大,用户的交互就会出现延时卡顿的情况。 除了滚动、拖动交互外,在for循环里对数据做格式修改,也会造成逻辑层和视图层频繁通讯。 其实这类通信损耗问题,在业内由来已久,react native和weex都有类似问题,weex提供了bindingx来解决。 但对于小程序来讲,这类问题解决起来更容易。其实视图层的webview,是有js环境的,只不过过去不给开发者开放。 如果在视图层的js直接处理滚动或拖动交互、直接处理数据格式,就能避免大量通信损耗。 但对于小程序平台而言,大量开放webview里的js编写,违反了它的初衷,比如开发者会直接操作dom,影响性能体验。所以小程序平台提出一种新规范,限制webview里可运行的js的能力。这就是wxs、sjs、filter的由来。 从本质来讲,wxs、sjs、filter是一种被限制过的、运行在视图层webview里的js。它并不是真的发明了一种新语言。 WXS特征及适用场景 WXS具备如下特征: · WXS是可以在视图层(webview)中运行的JS · WXS无法修改业务数据,仅能设置当前组件的class和style · WXS是被限制过的JavaScript,可以进行一些简单的逻辑运算 · WXS可以监听touch事件,处理滚动、拖动交互 故可以得出WXS的适用场景,主要包括: · 用户交互频繁、仅需改动组件样式(比如布局位置),无需改动数据内容的场景,比如侧滑菜单、索引列表、滚动渐变等 · 纯粹的逻辑计算,比如文本、日期格式化,通过WXS可以模拟实现Vue框架的,如下是一个通过wxs便捷实现首字母大写的示例: <wxs module=“m1”> //首字母大写 var capitalize = function(value) { if (!value) return ‘’ value = value.toString() return value.charAt(0).toUpperCase() + value.slice(1) } module.exports = { capitalize: capitalize } </wxs> <view class=“content”> <view class=“text-area”> <!-- title 为当前页面 data 中定义的初始数据 --> <text class=“title”>{{m1.capitalize(title)}}</text> </view> </view> uni-app如何支持WXS uni-app遵循,组件/样式/脚本是写在一个.vue文件中的,但微信小程序是多文件分离(wxml/wxss/js/json)的,所以在微信端的主要工作是扩展vue-template-compiler,解析template/style/script节点,并正确生成到对应的wxml/wxss/js文件中,具体编译工作如下图: 谜之wxs,uni-app如何用它大幅提升性能 Tips-1:关于<wxs>标签重构为<script lang=“wxs”>的说明: 因.vue文件中的<wxs>标签及内嵌WXS代码,在主流前端开发工具(vscode/HBuilderX等)中,均无法实现语法提示、代码高亮及格式化,故uni-app将<wxs module=“m1”>重构为<script module=“m1” lang=“wxs”>,便捷实现了语法提示、代码高亮等,如下为vscode/HBuilderX中对于<wxs>标签重构前后的代码高亮对比,明显重构为<script lang=“wxs”>后,开发体验更佳: 谜之wxs,uni-app如何用它大幅提升性能 Tips-2:鉴于Vue的自定义标签规范,我们建议将<wxs>(<script lang=“wxs”>)和template平级编写 编译器的具体解析扩展工作,这里不详述,仅给出wxs生成的示例代码,让大家有个直观理解: createFilterTag (filterTag, { content, attrs }) { content = content.trim() if (content) { //<wxs>标签内直接编写 wxs 代码 return [代码]<${filterTag} module="${attrs.module}"> ${content} </${filterTag}>[代码] } else if (attrs.src) { //外联 .wxs 文件 return [代码]<${filterTag} src="${attrs.src}" module="${attrs.module}"></${filterTag}>[代码] } } 在保证编译正确的情况下,微信小程序运行时会正确解析并执行WXS脚本,框架runtime无需干预。 基于 WXS 提升性能体验的实现示例 下面的gif图是借助 WXS 实现的一个swipeaction示例,列表项向左滑动时拉出几个挤压式联动的菜单按钮,跟手动画、回弹动画都很自然流畅。 谜之wxs,uni-app如何用它大幅提升性能 这里简单给出主要实现思路: 在 wxml 中引用 wxs 文件,并绑定 touch 事件 <template> <view class=“uni-swipe_content”> <!-- 可滑动的菜单项容器,绑定touch事件 --> <view :data-position=“pos” class=“move-hock” @touchstart=“swipe.touchstart” @touchmove=“swipe.touchmove” @touchend=“swipe.touchend” @change=“change”> <view class=“uni-swipe_box”> <slot /> </view> <view class=“uni-swipe_button-group move-hock”> <!-- 滑动后,右侧挤压式的联动菜单按钮–> <view v-for="(item,index) in options" :data-button=“btn” :key=“index” class=“button-hock”> {{ item.text }} </view> </view> </view> </view> </template> <script module=“swipe” lang=“wxs” src="./index.wxs"></script> 2. 在 wxs 文件中,处理 touch 事件逻辑,通过 translateX 移动元素位置 function touchstart(e, ins) { //记录开始位置及动画状态 var pageX = e.touches[0].pageX; … } function touchmove(e, ownerInstance) { var instance = e.instance; var pageX = e.touches[0].pageX;//获取当前移动位置 //计算偏移位置 var x = Math.max(-instance.getState().position[1].width, Math.min((value), 0)); //设置左侧元素移动位置 instance.setStyle({transform: ‘translateX(’ + x + ‘px)’}) //循环右侧挤压式联动菜单 var btnIns = ownerInstance.selectAllComponents(’.button-hock’); for (var i = 0; i < btnIns.length; i++) { … //设置每个联动菜单的移动位置 btnIns[i].setStyle({transform: ‘translateX(’ + (arr[i - 1] + value * (arr[i - 1] / position[1].width)) + ‘px)’}) … } } function touchend(e, ownerInstance) { var instance = e.instance; var state = instance.getState() //根据当前移动位置,实现菜单项的自动展开或回弹 move(state.left, -40, instance, ownerInstance) } 该示例的完整源码参考github 更多平台的兼容性 uni-app的App端也是一个小程序引擎,所以想要在App端实现流畅的跟手拖动,也需要实现类似wxs的机制。 其实H5平台倒不存在逻辑层和视图层通讯折损的问题,但为了平台兼容性拉齐,uni-app在H5端也实现了wxs机制。 这样编写wxs代码,在uni-app中可同时运行在App端、H5端、微信小程序端。 因百度小程序的、支付宝小程序的和微信小程序的在语法上差异较大,uni-app只支持单独编写百度小程序的Filter过滤器和支付宝小程序的SJS,这两种脚本无法跨多端,仅支持自有平台。开发者若需使用,可分别编写wxs/filter/sjs脚本,然后依次通过script引用,uni-app编译器会根据目标平台,分别编译发行,如下为示例代码: 示例代码要有条件编译 <!-- App/H5/微信小程序平台调用wxs脚本 --> <script module=“utils” lang=“wxs” src="./utils.wxs"></script> <!-- 百度小程序平台调用filter.js脚本 --> <script module=“utils” lang=“filter” src="./utils.filter.js"></script> <!-- 支付宝小程序平台调用sjs脚本 --> <script module=“utils” lang=“sjs” src="./utils.sjs"></script> 后续 用运行在视图层的js解决通讯阻塞,可能很多人都没意识到。希望本文能给大家解惑,解开WXS之谜。 其实小程序的性能体验优化,仍然有大量空间。DCloud团队在这个领域研究了6年,清楚当前的优势,也清楚当前的问题。我们会继续分享这些问题及对应的解决方案,为小程序产业发展贡献力量。 本文涉及的uni-ui的swiperaction组件,代码开源在https://github.com/dcloudio/uni-ui,uni-app框架代码开源在 https://github.com/dcloudio/uni-app,欢迎大家 star 或提交 pr。
2019-09-18 - 如何写出优雅的深复制
前言 无论在项目开发或者学习中,深拷贝已经是一个老生常谈的话题了,但是在实际中,如何优雅地写出深拷贝是我们值得思考的一个问题 内容 深拷贝 与 浅拷贝 深拷贝 将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象 浅拷贝 如果一个对象有着原始对象属性值的一份精确拷贝。如果这个对象属性是基本类型,那么拷贝的就是基本类型的值,如果属性是引用类型,那么拷贝的就是内存地址。 区别 其实深拷贝和浅拷贝的主要区别就是其在内存中的存储类型不同。 堆和栈都是内存中划分出来用来存储的区域。 栈(stack)为自动分配的内存空间,它由系统自动释放;而堆(heap)则是动态分配的内存,大小不定也不会自动释放。 对于js中的基本数据类型,他们的值被以键值对的形式保存在栈中。 [图片] 与基本类型不同的是,引用类型的值被保存在堆内存中,对象的引用被保存在栈内存中,而且我们不可以直接访问堆内存,只能访问栈内存。所以我们操作引用类型时实际操作的是对象的引用。 [图片] 了解相关的基础知识后,我们话不多说,直奔主图 简单版 在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。 [代码] JSON.parse(JSON.stringify()); [代码] 这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。 基础版 如果是浅拷贝对象时,我们可以很容易的就写出代码 [代码]function clone(obj) { let cloneObj = {}; for (const key in obj) { cloneObj [key] = obj[key]; } return cloneObj ; }; [代码] 对于浅拷贝而言,只需要简单地将对象的每一个属性进行复制即可。然而,对于深拷贝而言,我们拷贝对象的话是需要知道目标对象的属性是否是基本数据类型以及对象的深度。这些我们可以通过递归的方法来实现。 [代码]/* * 作用: 深复制对象属性 */ function clone(obj) { if (typeof obj=== 'object') { let cloneObj = {}; for (const key in obj) { cloneObj [key] = clone(obj[key]); } return cloneObj ; } else { return obj; } }; [代码] 这时候,我们实现了一个基础的深复制,那么问题来了,对于数组,该如何实现呢? 加深版 在上面的版本中,我们的初始化结果只考虑了普通的object,下面我们只需要把初始化代码稍微一变,就可以兼容数组了: [代码]function clone(obj) { if (typeof obj=== 'object') { let cloneObj = Array.isArray(obj) ? [] : {}; for (const key in target) { cloneObj[key] = clone(obj[key]); } return cloneObj; } else { return obj; } }; [代码] 在判断目标对象是引用类型时,则通过Array.isArray方法判断是否是数组,如果是则赋值为空数组,否则赋值为空对象。 循环引用 当对象子属性的值是父对象时,则递归的方法将不再适用。原因就是对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况,这将导致递归进入死循环导致栈内存溢出。 为了解决这个问题,我们可以通过WeakMap这种数据结构来实现。首先我们通过WeakMap来存储当前对象和拷贝对象的对应关系。当需要拷贝当前对象时,先去WeakMap中找,有则返回,无则set。 [代码]WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。 [代码] [代码]function clone(target, weakMap = new WeakMap()) { if (typeof target === 'object') { let cloneTarget = Array.isArray(target) ? [] : {}; if (weakMap.get(target)) { return weakMap.get(target); } weakMap.set(target, cloneTarget); for (const key in target) { cloneTarget[key] = clone(target[key], weakMap); } return cloneTarget; } else { return target; } }; [代码] 考虑一下性能,while循环的性能要比for on的要好,因此改造一下 [代码]function forEach(array, iteratee) { let index = 0; while (index < array.length) { iteratee(index, array[index]); index++; } return array; } function clone(target, weakMap = new WeakMap()) { if (typeof target === 'object') { const isArray = Array.isArray(target); let cloneTarget = Array.isArray(target) ? [] : {}; if (weakMap.get(target)) { return weakMap.get(target); } weakMap.set(target, cloneTarget); const keyList = isArray ? undefined : Object.keys(target); forEach( keyList || target , function(key, value){ if(keyList){ // 对象而言,其值才是他的key key = value; } cloneTarget[key] = clone(target[key], weakMap); }) return cloneTarget; } else { return target; } }; [代码] 其他数据类型 综上我们考虑到的只是普通的object以及array俩种数据类型,但引用类型并不单只有这俩个,还有很多。。。 判断是否是引用类型 在判断是否是引用类型时,我们可以通过typeof字段,此时我们还需要考虑typeof可能返回’function’字符串以及对象有可能是null的情况,因此可写出判断函数如下所示 [代码]function isObject(target) { const type = typeof target; return target !== null && (type === 'object' || type === 'function'); } [代码] 获取数据类型 我们可以使用toString来获取准确的引用类型: [代码]function getType(target) { return Object.prototype.toString.call(target); } [代码] [图片] [图片] 根据上面的返回的字符串,我们可以抽离出一些常用的数据类型以便后面使用: [代码]const mapTag = '[object Map]'; const setTag = '[object Set]'; const arrayTag = '[object Array]'; const objectTag = '[object Object]'; const boolTag = '[object Boolean]'; const dateTag = '[object Date]'; const errorTag = '[object Error]'; const numberTag = '[object Number]'; const regexpTag = '[object RegExp]'; const stringTag = '[object String]'; const symbolTag = '[object Symbol]'; [代码] 在上面的集中类型中,我们简单将他们分为两类: 可以继续遍历的类型 不可以继续遍历的类型 可继续遍历的类型 上面我们已经考虑的object、array都属于可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,另外还有Map,Set等都是可以继续遍历的类型 这时候我们需要一个通过对象原型上的constructor属性获取构造函数,从而对要复制的对象进行初始化。方法如下: [代码]function getInit(target) { const Ctor = target.constructor; return new Ctor(); } [代码] 下面我们改写一下clone函数,让他兼容map,set。 [代码]const mapTag = '[object Map]'; const setTag = '[object Set]'; const arrayTag = '[object Array]'; const objectTag = '[object Object]'; const deepTag = [mapTag, setTag, arrayTag, objectTag]; function clone(target, weakMap = new WeakMap()) { // 克隆基本数据类型 if (!isObject(target)) { return target; } // 初始化 const type = getType(target); let cloneTarget; if (deepTag.includes(type)) { cloneTarget = getInit(target, type); } // 防止循环引用 if (weakMap.get(target)) { return weakMap.get(target); } weakMap.set(target, cloneTarget); // 克隆set if (type === setTag) { target.forEach(value => { cloneTarget.add(clone(value,weakMap)); }); return cloneTarget; } // 克隆map if (type === mapTag) { target.forEach((value, key) => { cloneTarget.set(key, clone(value,weakMap)); }); return cloneTarget; } // 克隆对象和数组 const keys = type === arrayTag ? undefined : Object.keys(target); forEach(keys || target, (value, key) => { if (keys) { key = value; } cloneTarget[key] = clone(target[key], weakMap); }); return cloneTarget; } [代码] 不可继续遍历的类型 其他剩余的类型我们把它们统一归类成不可处理的数据类型,我们依次进行处理: Bool、Number、String、String、Date、Error这几种类型我们都可以直接用构造函数和原始数据创建一个新对象: [代码]function cloneOtherType(targe, type) { const Ctor = targe.constructor; switch (type) { case boolTag: case numberTag: case stringTag: case errorTag: case dateTag: return new Ctor(targe); case regexpTag: return cloneReg(targe); case symbolTag: return cloneSymbol(targe); default: return null; } } function cloneSymbol(targe) { return Object(Symbol.prototype.valueOf.call(targe)); } //克隆正则 function cloneReg(targe) { const reFlags = /\w*$/; const result = new targe.constructor(targe.source, reFlags.exec(targe)); result.lastIndex = targe.lastIndex; return result; } [代码] 克隆函数 对于克隆函数,实际上是没有太大的意义。。。因为不同的俩个对象使用同一个函数是没有任何问题的。 首先,我们可以通过prototype来区分下箭头函数和普通函数,箭头函数是没有prototype的。 我们可以直接使用eval和函数字符串来重新生成一个箭头函数,注意这种方法是不适用于普通函数的。 [代码]function cloneFunction(func) { const bodyReg = /(?<={)(.|\n)+(?=})/m; const paramReg = /(?<=\().+(?=\)\s+{)/; const funcString = func.toString(); if (func.prototype) { // 普通函数 const param = paramReg.exec(funcString); const body = bodyReg.exec(funcString); if (body) { if (param) { const paramArr = param[0].split(','); return new Function(...paramArr, body[0]); } else { return new Function(body[0]); } } else { return null; } } else { // 箭头函数 return eval(funcString); } } [代码] 总结 综上,我们围绕深复制进行了解析,了解到了应该如何写出优雅的深复制,在实际开发中,可以根据不同的场景,合理的选择如何书写深复制。
2019-11-15 - 微盟小程序性能优化实践(上)
微盟小程序性能优化要分享的内容分为三部分,启动性能加载、首屏加载的体验建议和渲染性能优化。 今天主要讲启动性能加载的性能优化实践,先看启动加载过程的流程: [图片] · 公共库注入 · 资源准备(基础UI创建,代码包下载) · 业务代码注入和渲染 · 渲染首屏 · 异步请求 优化方案 1、控制代码包大小 · 开启开发者工具中的 “ 上传代码时自动压缩 ” · 及时清理无用代码和资源文件 · 减少代码包中的图片等资源文件的大小和数量 · 将图片等资源文件放到CND中 · 提取公共样式 · 代码压缩,图片格式,压缩,或者外联 · 公共组件提取,代码复用 2、 分包加载 分包加载过程流程 [图片] 在开发小程序分包项目时,会有一个或者多个分包,其中没有分包小程序必须包含一个主包,即放置启动页面或者tabBar页面,以及一些分包都需要用到的公共资源脚本。 在小程序启动时,默认会下载主包并且启动主包内页面,如果用户打开分包内的页面,客户端会把分包下载下来,下载完之后再进行展示。 · 分包加载流程 [图片] 使用分包加载的优点: · 能够增加小程序更大的代码体积,开发更多的功能 · 对于用户,可以更快地打开小程序,同时不影响启动速度 使用分包加载有哪些限制: · 整个小程序所有分包不能超过8M · 单个主包/分包不能超过2M 3、 运行机制优化 · 代码中减少立即执行的代码数量 · 避免高开销和长时间阻塞代码 · 业务代码都写入页面的生命周期中 · 做好缓存策略 4、 数据管理优化 · 首屏请求数量尽量不能超过5个,超过的可以做接口合并(node层,服务端都可以处理) · 对多次提交的数据可以做合并处理 首屏加载的体验建议和渲染性能优化这两部分的内容,将在下次分享给大家。微盟小程序性能优化实践(下)
2018-09-25