着各大平台小程序的快速放量,开发者遇到越来越多的平台适配问题。各平台小程序的性能优化方法也各不相同,我们该如何应对?DCloud CTO 崔红保在 GMTC 深圳 2019(全球大前端技术大会)分享了《小程序的未来方向》,介绍了小程序技术架构、性能卡点以及各平台优化方案,对于小程序未来的技术更迭,提出了小程序在未来可能的发展方向。本文根据演讲内容整理而成。
简单介绍一下我自己,中年码农,跨平台开发领域的老兵。在那个翻盖摩托罗拉手机代表着先进和时髦的年代,我就开始参与 “window mobile/j2me/symbain” 等系统的跨平台研发管理工作,可能很多同学都没见过那些手机。到后来的移动互联网时代及当下的小程序时代,我也一直在深度参与其中,持续输出“Hybrid App”引擎、前端 UI 库(mui)及小程序跨端开发框架(uni-app)。目前在 DCloud 任职 CTO,同时兼 “uni-app” 产品负责人。
罗马不是一天建成的,小程序也不是一天发明的。小程序这种介于 H5 和 Native App 之间的特殊应用形态,从探索到成熟,经历了哪些过程?我们首先带大家回顾梳理一下。然后,从现有技术架构出发,分析小程序当下几个主要性能坑点。各家小程序引擎为解决这些坑点,做了哪些完善工作。比如,大家知道小程序是以 Web 渲染为主、原生渲染为辅,那引入原生渲染后,引发了哪些新的问题?为解决这些问题,微信提出了同层渲染的方案,同层渲染在技术层面上又是如何实现的?最后从当前已知问题出发,对于小程序未来的技术更迭,抛出一些我们认为的可能方向,供大家参考。
HTML5 于 2007 年在 W3C 立项,与 iPhone 发布同年。
乔布斯曾期待 HTML5 能帮助 iPhone 打造起应用生态系统。但 HTML5 的发展速度并不如预期,虽然它成功地打破了 IE+Flash 垄断的局面,却没有达到承载优秀的移动互联网体验的地步。
苹果公司在 iPhone 站稳脚跟后,紧接着发布了自己的 App Store,开启了移动互联网的原生应用时代。
大家知道现在手机端主要是 iOS、Android 两大系统,实际上在早期有 3 大系统竞争,还有一个就是诺基亚的 MeeGo 系统,MeeGo 采用 C + HTML5 的双模应用生态策略。然而,C 的开发难度太大,HTML5 体验又不行,所以后来 MeeGo 就掉队了;与之对应,Android 依靠 Java 技术生态,在竞争中脱颖而出。
于是在移动互联网初期,应用生态被定了基调 —— 原生开发。
国内有一批做浏览器的厂商,尝试去改进 HTML5。比如,百度在 2013 年的百度世界大会上发布了轻应用,通过给 WebView 扩展原生能力,补充 JS API,让 HTML5 应用可以实现更多功能。
这类业务发展的顶峰,是微信在 2015 年初发布的微信 JS SDK,作为国内事实上最大的手机浏览器,微信为它的浏览器内核扩充了大量 JS API,让开发者可以用 JS 调用微信支付、扫码等众多 HTML5 做不到的功能。
不过这类业务没有取得成功,HTML5 的问题不止是功能不足,性能体验是更严重的问题。而体验问题,不是简单地扩展 JS 能力能搞定的。
与浏览器不同,Hybrid 应用是另一个细分领域,开发者使用 JS 编写应用,为了让 JS 应用更接近原生应用的功能体验,这个行业的从业者做出了很多尝试。我们 DCloud 公司是业内主流 Hybrid App 引擎提供方之一,我们提出了改进 HTML5 的“性能功能”障碍的解决方案 —— 通过工具、引擎优化、开发模式调整,让开发者可以通过 JS 写出更接近原生 App 体验的应用。
多 WebView 模式,原生接管转场动画、下拉刷新、Tab 分页,预载 WebView……各种优化技术不停迭代,终于让 Hybrid 应用取得了性能体验的突破。
Hybrid 应用和轻应用、微信 JS SDK 等基于浏览器增加方案相比,还有一个巨大的差别:一个是 Client/Server,一个是 Browser/Server。简单来说,Hybrid 应用是 JS 编写的需要安装的 App,而轻应用是在线网页。
C/S 的应用在每次页面加载时,仅需要联网获取 JSON 数据;而 B/S 应用除了 JSON 数据外,还需要每次从服务器加载页面 DOM、样式、逻辑代码,所以 B/S 应用的页面加载很慢,体验很差。
可是这样的 C/S 应用虽然体验好,却失去了 HTML5 的动态性,仍然需要安装、更新,无法即点即用、直达二级页面。
那么 C/S 应用的动态性是否可以解决呢?对此, DCloud 率先提出了“流应用”概念,把之前 Hybrid 应用里的运行于客户端的 JS 代码,先打包发布到服务器,制定流式加载协议,手机端引擎动态下载这些 JS 代码到本地,并且为了第一次加载速度更快,实现了应用的边下载边运行。
就像流媒体的边下边播一样,应用也可以实现边用边下。
在这套方案的保障下,终于解决了之前的各种难题:让 JS 应用功能体验达到原生,并且可即点即用、直达二级页面。
接着就是微信小程序,最初的名字实际上是微信应用号,之后改名为小程序,2016 年 9 月份内测,2017 年 1 月正式发行,再之后阿里巴巴、手机厂商联盟、百度、,陆续推出了自己的小程序平台,小程序时代滚滚而来。
2018 年 9 月,微信推出云开发,这个功能我们认为是小程序发展历史上的一个重要节点,它可以让前端工程师从前到后将所有业务闭环实现,减少前后端的沟通成本、人力成本、运维成本,属于开发模式的重大升级。与之前的前端同学既可通过 JS/CSS 编写前端 UI,又可通过 “Node.js” 写后端业务,这种所谓全栈开发模式相比,云开发有更好的优势,因为前端同学对于 DB 优化、弹性扩容、攻击防护、灾备处理等方面还是有经验欠缺的,但云开发将这些都封装好了,真正做到仅专注业务实现,其它都委托云厂商服务。
这是一个比较通用的小程序架构,目前几家小程序架构设计大致都是这样的(快应用的区别是视图层只有原生渲染)。
大家知道小程序是一个逻辑、视图层分离的架构。
逻辑层就是上图左上角这块,小程序中开发的所有页面 JS 代码,最后都会打包合并到逻辑层,逻辑层除了执行开发者的业务 JS 代码外,还需处理小程序框架的内置逻辑,比如 App 生命周期管理。
视图层就是上图右上角这块,用户可见的 UI 效果、可触发的交互事件在视图层完成,视图层包含 Web 组件、原生组件两种,也就是小程序是原生 +Web 混合渲染的模式,这块后面会详细讲。
逻辑层最后运行在 JS CORE 或 V8 环境中;JS CORE 既不是 DOM 环境,也不是 Node 环境,你是无法使用 JS 中的 DOM 或 BOM 对象的,你能调用的仅仅是 ECMAScript 标准规范中所给出的方法。
那如果你要发送网络请求怎么办?window.XMLHttpRequest 是无法使用的(当然即使可以调用,在 iOS 的 WKWebView 中也存在更严格的跨域限制,会有问题)。这时候,网络请求就需要通过原生的网络模块来发送,JS CORE 和原生之间呢,就需要这个 JS Bridge 来通讯。
小程序这种架构,最大的好处是新页面加载可以并行,让页面加载更快,且不卡转场动画;但同时也引发了部分性能坑点,今天主要介绍 3 点:
我们从“swipeaction”这个例子讲起,需求是用户在列表项上向左滑动,右侧隐藏的菜单跟随用户手势平滑移动。
若想在小程序架构上实现流畅的跟手滑动,是很困难的,为什么?
回顾一下小程序架构,小程序的运行环境分为逻辑层和视图层,分别由 2 个线程管理,小程序在视图层与逻辑层两个线程间提供了数据传输和事件系统。这样的分离设计,带来了显而易见的好处:
环境隔离,既保证了安全性,同时也是一种性能提升的手段,逻辑和视图分离,即使业务逻辑计算非常繁忙,也不会阻塞渲染和用户在视图层上的交互。
但同时也带来了明显的坏处:
视图层(WebView)中不能运行 JS,而逻辑层 JS 又无法直接修改页面 DOM,数据更新及事件系统只能靠线程间通讯,但跨线程通信的成本极高,特别是需要频繁通信的场景。
基于这样的架构设计,我们回到“swipeaction”,分析一次 touchmove 的操作,小程序内部的响应过程:
(1)用户拖动列表项,视图层触发 touchmove 事件,经 Native 层中转通知逻辑层(逻辑层、视图层不是直接通讯的,需 Native 中转),即下图中的?、?两步;
(2)逻辑层计算需移动的位置,然后再通过 setData 传递位置数据到视图层,中间同样会由微信客户端(Native)做中转,即下图中的?、?两步。
实际上,用户滑动过程中,touchmove 的回调触发是非常频繁的,每次回调都需要 4 个步骤的通讯过程,高频率回调导致通讯成本大幅增加,极有可能导致页面卡顿或抖动。为什么会卡顿,因为通讯太过频繁,视图层无法在 16ms 内完成 UI 更新。
为解决这种通讯阻塞的问题,各家小程序都在逐步提供对应的解决方案,比如微信的 WXS、支付宝的 SJS、百度的 Filter,但每家小程序支持情况不同,详细见下表。
另外,微信的“关键帧动画”、百度的“animation-view” Lottie 动画,也是为减少频繁通讯的一种变更方式。
其实,通讯阻塞是业界普遍存在的一个问题,不止小程序,“React Native”“Weex”等同样存在通讯阻塞的问题。只不过“React Native”“Weex”的视图层是原生渲染,而小程序是 Web 渲染。我们下面以“Weex”为例来说明。
大家知道,“Weex”底层使用的 JS-Native Bridge,这个 Bridge 使得 JS 和 Native 之间的通信会有固定的性能损耗。
继续以上述“swipeaction”为例,要实现列表项菜单的跟手滑动,大致需经如下流程:
(1)在 UI 视图上绑定 touch 事件(或 pan 事件);
(2)当手势触发时, Native UI 层将手势事件通过 Bridge 传递给 JS 逻辑层 , 这产生了一次 Native UI 到 JS 逻辑的通信,即下图中的?、?两步 ;
(3)JS 逻辑在接收到事件后,根据手指移动的偏移量驱动界面变化,这又会产生一次 JS 到 Native UI 的通信,即下图中的?、?两步。
同样,手势回调事件触发的频率是非常高的,频繁的的通信带来的时间成本很可能导致界面无法在 16ms 中完成绘制,卡顿也就产生了。
“Weex”为解决通讯阻塞,提供了“BindingX”解决方案,这是一种称之为“Expression Binding”的机制,简要介绍一下:
(1)接收手势事件的视图,在移动过程中的偏移量以“x,y”两个变量表示;
(2)期望改变(跟随移动)的视图,变化的属性为“translateX”和“translateY”,对应变化的偏移量以“f(x),f(y)”表达式表示;
(3)将”交互行为 " 以表达式的方式描述,并提前预置到 Native UI 层;
(4)交互触发时,Native UI 根据其内置的表达式解析引擎,去执行表达式,并根据表达式执行的结果驱动视图变换,这个过程无需和 JS 逻辑通讯。
伪代码 - 摘录自 Weex 官网
复制代码
{ anchor: foo_view.ref // ----> 这是 " 产生手势的视图 " 的引用 props: [ { element: foo_view.ref, // ----> 这是 " 期望改变的视图 " 的引用 expression: f(x)=x, // ----> 这是具体的表达式 property: translateX // ----> 这是期望改变的属性 }, { element: foo_view.ref, expression: f(y)=y, // ----> y 属性 property: translateY } ]}
“React Native”同样存在类似问题,为避免频繁的通信,“React Native”生态也有对应方案,比如“Animated”组件及 Lottie 动画支持。以 “Animated”组件为例,为实现流畅的动画效果,该组件采用了声明式的 API,在 JS 端仅定义了输入与输出以及具体的 transform 行为,而真正的动画是通过 Native Driver 在 Native 层执行,这样就避免了频繁的通信。然而,声明式的方式能够定义的行为有限,无法胜任交互场景。
“uni-app”在 App 端同样面临通讯阻塞的问题,我们目前的方案是采用类似微信 WXS 的机制(内部叫“renderjs”),但放开了 WXS 中无法获取页面 DOM 元素的限制,比如下图中多个小球同时移动的 canvas 动画,“uni-app”在 App 端的实现方案是:
(1)renderjs 中获取 canvas 对象;
(2)基于 web 的 canvas 绘制动画,而非原生 canvas 绘制。
Tips:大家需要注意,并不是所有场景都是原生性能更好,小程序架构下,如上多球同时移动的动画,原生 canvas 并不如在 WXS(renderjs)中直接调用 Web canvas
下表总结了跨端框架在通讯阻塞方面的解决方案:
小程序架构存在通讯阻塞问题,厂商为解决这个问题,创造了“WXS”脚本语言及关键帧动画等方式,但这些都是厂商维度的优化方案。我们作为小程序开发者,在性能优化方面,又能做哪些工作呢?
小程序开发性能优化,核心就是“setData”的调用,你能做只有两件事情:
(1)减少 setData 调用次数
假设我们有更改多个变量值的需求,示例如下:
change:function(){
? ? this.setData({a:1});
? ? ... // 其它业务逻辑
? ? this.setData({b:2});
? ? ... // 其它业务逻辑
? ? this.setData({c:3});
? ? ... // 其它业务逻辑
? ? this.setData({d:4});
}
如上,4 次调用“setData”,会引发 4 次逻辑层、视图层数据通讯。这种场景,开发者需意识到“setData”有极高的调用代价,自己需手动调整代码,合并数据,减少数据通讯次数。
部分小程序三方框架已内置数据合并的能力,比如“uni-app”在 Vue runtime 上进行了深度定制,开发者无需关注“setData”的调用代价,可放心编写如下代码:
change:function(){
? ? this.a=1;
? ? ... // 其它业务逻辑
? ? this.b=2;
? ? ... // 其它业务逻辑
? ? this.c=3;
? ? ... // 其它业务逻辑
? ? this.d=4;
}
如上 4 次赋值,uni-app 运行时会自动合并成“{“a”:1,“b”:2,“c”:3,“d”:4}”一条记录,调用一次“setData”完成所有数据传递,大幅降低 setData 的调用频次,结果如下图:
减少“setData”调用次数,还有个注意点:后台页面(用户不可见的页面)应避免调用“setData”。
(2)数据差量更新
假设我们有一个 “列表页 + 上拉加载” 的场景,初始化列表项为 “item1 ~ item4”,用户上拉后要向列表追加 4 条新记录 “item5 ~ item8”,小程序代码如下:
page({
? ? data:{
? ? ? ? list:['item1','item2','item3','item4']
? ? },
? ? change:function(){
? ? ? ? let newData=['item5','item6','item7','item8'];
? ? ? ? this.data.list.push(...newData); // 列表项新增记录
? ? ? ? this.setData({
? ? ? ? ? ? list:this.data.list
? ? ? ? })
? ? }
})
如上代码,change 方法执行时,会将 list 中的 “item1 ~ item8”8 个列表项通过“setData”全部传输过去,而实际上变化的数据只有“item5 ~ item8”。
开发者在这种场景下,应通过差量计算,仅通过“setData”传递变化的数据,如下是一个示例代码:
page({
? ? data:{
? ? ? ? list:['item1','item2','item3','item4']
? ? },
? ? change:function(){
? ? ? ? // 通过长度获取下一次渲染的索引
? ? ? ? let index=this.data.list.length;
? ? ? ? let newData=['item5','item6','item7','item8'];
? ? ? ? let newDataObj={};// 变化的数据
? ? ? ? newData.forEach((item)=> {
? ? ? ? ? ? newDataObj['list[' + (index++) + ']']=item;// 通过 list 下标精确控制变更内容
? ? ? ? });
? ? ? ? this.setData(newDataObj) // 设置差量数据
? ? }
})
每次都手动计算差量变更数据是繁琐的,新手不理解小程序原理的话,也容易忽略这些性能点,给 App 埋下性能坑点。
此处,建议开发者选择成熟的第三方小程序框架,这些框架已经自动封装差量数据计算,对开发者更友好。比如,“uni-app”借鉴了 “westore JSON Diff”库,在调用 setData 之前,会先比对历史数据,精确高效计算出有变化的差量数据,然后再调用 setData,仅传输变化的数据,这样可实现传递数据量的最小化,提升通讯性能。如下,是一个示例代码:
export default{
? ? data(){
? ? ? ? return {
? ? ? ? ? ? list:['item1','item2','item3','item4']
? ? ? ? }
? ? },
? ? methods:{
? ? ? ? change:function(){
? ? ? ? ? ? let newData=['item5','item6','item7','item8'];
? ? ? ? ? ? this.list.push(...newData) // 直接赋值,框架会自动计算差量数据
? ? ? ? }
? ? }
}
Tips:如上 change 方法执行时,仅会将 list 中的 “item5 ~ item8”4 个新增列表项传输过去,实现了 setData 传输量的极简化。
(3)组件差量更新
下图是一个微博列表截图:
假设当前有 200 条微博,用户对某条微博点赞,需实时变更其点赞数据(状态);在传统模式下,一条微博的点赞状态变更,会将整个页面 (Page) 的数据全部通过 setData 传递过去,这个消耗是非常高的;而即使通过之前介绍,通过差量计算的方式获取变更数据,这个 Diff 遍历范围也很大,计算效率极低。
如何实现更高性能的微博点赞?这其实就是组件更新的典型场景。
合适的方式应该是,将每条微博封装成一个组件,用户点赞后,仅在当前组件范围内计算差量数据(可理解为 Diff 范围缩小为原来的 1/200),这样效率才是最高的。
提醒大家注意,并不是所有小程序三方框架都已实现自定义组件,只有在基于自定义组件模式封装的框架中,性能才会大幅提升;如果三方框架是基于老的“template”模板封装的组件开发,则性能并不会有明显改善,其 Diff 对比范围依然是 Page 页面级的。
大家知道,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是由原生客户端渲染的。
小程序中的原生组件,从使用方式上来说,主要分为三类:
除了上面提到的这些之外,其它基本都是 Web 渲染。所以说,小程序是混合渲染模式,Web 渲染为主,原生渲染为辅。
(1)为什么要引入混合渲染
接下来的问题,为什么要引入原生渲染?以及为什么仅针对这几个组件提供了原生增强?其他组件为什么没有做原生实现?
这就需要我们针对每个组件单独进行分析思考,这里举了几个例子:
提到“input”控件的原生化,可以稍微发散一下。
小程序中原生 input 控件的通用做法是,未获取焦点时以 Web 控件显示,但在获取焦点时,绘制一个原生 input,盖在 Web input 上方,此时,用户看见的键盘即为原生 input 所对应的键盘,原生弹出键盘是可自定义按钮(如上图中下一步、send 按钮)。这种做法存在一个缺陷: Web 和原生,毕竟不同渲染引擎,在键盘弹出和关闭时,对应 input 的“placeholder”会闪烁。
在 Android 平台,还有一种做法是基于 WebKit 改造,定制弹出键盘样式;这种方案,在键盘弹出和关闭时,input 控件都是 Web 实现的,故不存在“placeholder”闪烁的问题。
(2)混合渲染引发的问题
原生组件虽然带来了更丰富的特性及更好的性能,但同时也引入了一些新的问题,比如:
当然,并不是所有小程序都存在这种问题,部分小程序通过修改自带的 WebView 内核,实现了 WebView 也可以使用 rom 主题字体,比如微信、QQ、支付宝;其他小程序(百度、头条),WebView 仍然无法渲染为 rom 主题字体。
(3) 混合渲染改进方案
既然混合渲染有这些问题,对应就会有解决方案,目前已有的方案如下。
既然其它组件无法覆盖到原生组件上,那就创造出一种新的组件,让这个新组件可以覆盖到 video 或 map 上。“cover-view/cover-image”就是基于这种需求创造出来的新组件;其实它们也是原生组件,只不过层级略高,可以覆盖在 map、video、canvas、camera 等原生组件上。
目前除了字节跳动外,其它几家小程序均已支持“cover-view/cover-image”。
cover-view/cover-image 在一定程度上缓解了分层覆盖的问题,但也有部分限制,比如严格的嵌套顺序。
既然分层有问题,那就消除分层,从 2 层变成 1 层,所有组件都在一个层中,“z-index”岂不就可生效了?
这个小目标说起来简单,具体实现还是很复杂的。
抛开小程序当前架构实现,解决混合渲染最直接的方案,应该更换渲染引擎,全部基于原生渲染,video/map 和 image/view 均为原生控件,层级相同,层级遮盖问题自然消失。这正是“uni-app”在 App 端的推荐方案。
当前 Web 渲染为主、原生渲染为辅的主流小程序现状,如何实现同层渲染?
基于我们的分析研究,这里简单讲解一下同层渲染实现的方案,和微信真实实现可能会有出入(目前仅微信一家实现了同层渲染)。
(1) iOS 平台
小程序在 iOS 端使用 WKWebView 进行渲染,WKWebView 在内部采用的是分层渲染,一般会将多个 DOM 节点,合并到一个层上进行渲染。因此,DOM 节点和层之间不存在一一对应关系。但是,一旦将一个 DOM 节点的 CSS 属性设置为 “overflow: scroll” 后,WKWebView 便会为其生成一个 WKChildScrollView,且 WebKit 内核已经处理了 WKChildScrollView 与其他 DOM 节点之间的层级关系,这时 DOM 节点就和层之间有一一对应关系了。
小程序 iOS 端的同层渲染可基于 WKChildScrollView 实现,主要流程如下:
(2)Android 平台
小程序在 Android 端采用 Chromium 作为 WebView 渲染层,和 iOS 的 WKWebView 不同,是统一渲染的,不会分层渲染。但 Chromium 支持 WebPlugin 机制,WebPlugin 是浏览器内核的一个插件机制,可用来解析“< embed >”。Android 端的同层渲染可基于 “< embed >”加 Chromium 内核扩展来实现,大致流程如下:
这个流程相当于给 WebView 添加了一个外置插件,且“< embed >”节点是真正的 DOM 节点,可将更多的样式作用于该节点上。
如果要探讨小程序接下来的技术升级方向,我们认为应该在用户体验、开发效率两个方向上努力。
先说用户体验的问题,主要也是两个方面:
如果你也想快速搭建的自己的小程序引擎,并更优的解决如上体验问题,该怎么办?
uni-app 发行到 App 端,实际上就是一个完整的小程序引擎,DCloud 会在近期将这个引擎完整开源,欢迎大家基于 uni-app 小程序 SDK 快速打造自己的小程序平台。
uni-app 小程序 SDK 具备如下几个特征:
开发效率应该从跨端、跨云两个维度进行分析。
目前的小程序都带有明显的厂家属性,每个厂家各不相同。比如,阿里内部有多套小程序(支付宝、淘宝、钉钉等),幸好阿里内部目前已基本统一。但腾讯体系下,微信和 QQ 小程序依然是两队人马,两套规范。
小程序之前是手机端的,2019 年 360 出了 PC 端小程序。
接下来,会不会还有其它厂家推出自己的小程序?会不会有新的端的出现?比如,面向电视的小程序、面向车载的小程序?
一切皆有可能。
逐水草而居是人类的本能,追求流量依然是互联网的制胜法宝。当前的小程序宿主,都是亿级流量入口,且各家流量政策不同。比如,微信的流量虽然很大,但有各种限制;百度和头条是支持广告投放的,通过广告投放,可以快速获得大量较为精准的用户;百度小程序还有个 Web 化的功能,可以将 Web 的搜索流量,转化成小程序的流量。
面对众多小程序平台及各自巨大的入口流量,开发者如何应对?
等待 W3C 的小程序标准统一,短期不太现实。当下,若想将业务快速触达多家小程序,借助跨端框架应该是唯一可行的方案。
开发商借助“uni-app”或其它跨端框架,虽然已可以开发所有前端应用。但仍然需要雇佣 PHP 或 Java 等后台开发人员,既有后端人员成本,又有前 / 后端沟通成本。
腾讯、阿里、百度小程序虽陆续上线了云开发,但它们均只支持自己的小程序,无法跨端,分散的服务器对开发商更不可取。
故我们认为跨厂商的 Serverless 是接下来的一个重点需求,开发者在一个云端存储所有业务数据及后端逻辑,然后将前端小程序发行到各家小程序平台,也就是“一云多端”模式。
基于小程序的现状,我们也许可以总结一下小程序技术上的可能方向:
作者介绍:
崔红保,DCloud CTO,Uni-App 团队负责人,开发了 2 个 Github Star 上万的流行项目。有 10 年以上研发管理经验,在跨平台引擎、前端 UI、小程序性能优化等方面有丰富的实践经验。
性渐变和径向渐变《HTML5系列教程19》
线性渐变和径向渐变《HTML5系列教程19》
渐变在我们的日常生活中是一种随处可见的非常普遍的一种视角形象。Canvas为实现渐变效果提供了很好的解决方案。在HTML5中主要有两种渐变方式,一种是沿着直线的渐变方式,另一种是从一个点或圆的半径向四周渐变的方式,我们把这两种方式叫做线性渐变和径向渐变。今天我们主要介绍这两种渐变的绘制方法。
1.线性渐变的绘制方法
Canvas提供了用于创建线性渐变的函数createLinearGRadient(x0,y0,x1,y1),坐标点是(x0,y0)指定了线性渐变的起点,坐标点(x1,y1)指定了线性渐变的终点,如果这两个坐标点在一条水平线上,那么就会创建水平线性渐变;如果这两个坐标点在一条垂直线上,那么将创建垂直线性渐变;如果这两个坐标点连线是一条倾斜的直线,那么将创建倾斜线性渐变。那么假设现在我们要创建一个宽度为300的水平线性渐变,示例代码如图所示:
HTML5中宽度为300的水平线性渐变的绘制代码
有了一个渐变对象之后,我们就需要定义渐变的颜色了。在Canvas中使用addColorStop(stop,color)函数来定义渐变的颜色,参数stop表示开始渐变位置占渐变区域的百分比,为0~1之间的任意值,参数color为颜色样式。在实际应用中,至少要添加两种以上颜色才能达到渐变效果。例如要创建从红色到蓝色的渐变,可以使用图中这个示例代码:
HTML5中渐变颜色的定义
接下来我们需要设置Canvas内容的fillStyle为当前的渐变对象,并且绘制图形,比如一个矩形或一条直线。所以,为了看到渐变效果,我们还需要以下代码:
HTML5中绘制渐变图形
到这里,一个线性渐变的图形就绘制完成了,完整的示例代码如图:
HTML5中绘制红蓝渐变的代码示例
在谷歌浏览器上预览效果如图:
HTML5中绘制红蓝渐变的效果示例
2.径向渐变的绘制
径向渐变与线性渐变的实现方式基本类似,在Canvas中使用createRadialGradient(x0,y0,r0,x1,y1,r1)函数创建一个沿两个圆之间的锥面绘制渐变。前三个参数代表圆心为(x0,y0),半径为r0的开始圆,后三个参数代表圆心为(x1,y1),半径为r1的结束圆。创建该对象后,仍需要使用addColorStop函数定义渐变颜色,并设置径向渐变对象为fillStyle的当前渐变对象,最后绘制一个渐变图形,完成径向渐变的绘制。绘制径向渐变的示例代码如图所示:
HTML5中径向渐变的绘制代码示例
在谷歌浏览器上预览效果如图:
HTML5中径向渐变的绘制效果示例
还有一点要注意的是在绘制径向渐变时,可能会因为Canvas的宽度或高度设置不合适,导致径向渐变显示不完整,这时候就要考虑跳转Canvas的尺寸了,以便能完整显示径向渐变的效果。这一次线性渐变和径向渐变就是这些了。谢谢大家的观看。祝大家:身体健康、生活愉快!
html5Canvas的知识点,是开发的必备技能,在实际工作中也常常会涉及到。
最近熬夜总结html5Canvas相关的知识点,大家一起看一下吧:
Canvas使用的场景有:1,动画;2,H5游戏;3,图表。
效果动画,加载Loading:
H5游戏效果:
对于Canvas需要掌握:
Canvas坐标体系
画直线,矩形和原型
beginPath和closePath
描边和填充样式
Canvas中的图形变换,渐变,文字和图片
Canvas中的图形变换
图形变换都是针对坐标系来说的:
save和restore
用来保存和恢复上下文的环境ctx,一般成对出现
Canvas中的渐变
(xStart,yStart)是线段的起点,(xEnd,yEnd)是线段终点。起点到终点之间的颜色呈渐变。
ctx.createRadialGradient(xStart,yStart, radiusStart,xEnd,yEnd,radiusEnd);
(xStart,yStart)是第一个圆的原心,radiusStart是第一个圆的半径,(xEnd,yEnd)是第二个圆的原心,radiusEnd是第二个圆的半径
第一圆到第二个圆之间的颜色呈现渐变。
Canvas中的文字
描边文字:ctx.strokeText(text,x,y)
填充文字:ctx.fillText(text,x,y);
设置字体样式:ctx.font
设置垂直对齐方式:ctx.textBaseline
Canvas图片
绘制图片3种方法
在image加载完成之后绘制:
示例:
var img=new Image();img.src='logo.png';img.onload=function() { ctx.drawImage(img,0,0,40,40,0,0,80,80);}
Canvas绘制
Canvas图形画刷
ctx.createPattern可以创建一个画刷模式,进而可以设置到fillStyle里,进行画刷的填充。
type取值:
Canvas像素操作
var imageData=ctx.getImageData(x,y,w,h)返回的是一维数组:[r1,g1,b1,a1,r2,g2,b2,a2...]
ctx.putImageData(imageData,x,y)把imageData放在(x,y)处
ctx.putImageData(imageData, x, y, dirtyX, dirtyY, dirtyW, dirtyH)只显示(dirtyX,dirtyY)处的宽dirtyW,dirtyH的区域
Canvas阴影绘制
Canvas剪辑区域
Canvas绘制曲线
狐线:
context.arc(x,y,radius, starAngle,endAngle, anticlockwise)圆心(x,y) 半径radius从starAngle到endAngleanticlockwise代表是否逆时针方向
生成工具
Canvas Quadratic Curve Example
http://blogs.sitepointstatic.com/examples/tech/canvas-curves/quadratic-curve.html
http://blogs.sitepointstatic.com/examples/tech/canvas-curves/bezier-curve.html
二次样条曲线:
context.quadraticCurveTo(qcpx,qcpy, qx,qy)
贝塞尔曲线:
context.bezierCurveTo(cp1x,cp1y, cp2x, cp2y, x,y)
清除(x,y)点起, 宽width,高height的区域,用于重新绘制
离屏技术是什么:通过在离屏Canvas中绘制元素,再复制到显示Canvas中,从而大幅提高性能的一种技术。
使用离屏技术:
离屏技术:
一个Canvas中的图形绘制到另一个Canvas方法:
ctx.drawImage(canvas,x,y),该方法把canvas绘制在(x,y)处ctx.drawImage(canvas,x,y, w,h),该方法把canvas绘制在(x,y)处,并缩放为宽w,高hctx.drawImage(canvas, sx, sy, sw, sh, dx, dy, dw, dh),该方法把canvas中(sx, sy)处的宽sw,高sh的区域,绘制到(dx,dy)处,并缩放为宽dw, 高dh
对canvas插件的相关了解
什么是Canvas插件,掌握Chart.js插件,了解Chartist.js和HighCharts.js插件
(图表)Chart.js插件:https://www.chartjs.org/
Chartist.js插件是一个简单的响应式图表插件:支持SVG格式(http://gionkunz.github.io/chartist-js/)
HighCharts.js插件:方便快捷的HTML5交互性图标库:https://www.highcharts.com/
Chartist.js插件与HighCharts.js插件
响应式布局,它的用户体验友好,响应式网站可以根据不同终端,不同尺寸和不同应用环境,自动调整界面布局,展示内容,提供非常好的视觉效果。响应式布局就是一个网站能够兼容多个终端
示例:
<style>#canva { border: 1px solid red;}</style><div><canvas id="canva" width="200" height="200"></canvas>// 绘制宽高200的canvas</div>
在开始绘图时,先要获取Canvas元素的对象,在获取一个绘图的上下文。
获取Canvas对象 ,使用document对象的getElementById()方法获取。
var canvas=document.getElementById("canvas")可以使用通过标签名称来获取对象的getElementsByTagName方法
使用getContext()方法来获取
var context=canvas.getContext("2d")
context.font="98px 黑体"; // 文字样式context.fillStyle="red"; // 文字颜色context.textAlign="center"; // 文字对齐方式// 绘制文字context.fillText("达达前端",100, 123, 234);
绘制图像:
使用drawImage()方法可以将图像添加到Canvas画布中,绘制一幅图像,需要有三个重载的方法:
使用:
drawImage(image, x, y) // 在画布上定位图像// 方法在画布上绘制图像、画布或视频。// 方法也能够绘制图像的某些部分,以及/或者增加或减少图像的尺寸。drawImage(image, x, y, width, height)// 在画布上定位图像,并规定图像的宽度和高度drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, destX, destY, destWidth, destHeight)// 剪切图像,并在画布上定位被剪切的部分
参数:
参数描述image规定要使用的图像,画布或视频sourceX开始剪切的x坐标位置sourceY开始剪切的y坐标位置sourceWidth被剪切图像的宽度sourceHeight被剪切图像的高度destX在画布上放置图像的 x 坐标位置destY在画布上放置图像的 y 坐标位置destWidth要使用的图像的宽度destHeight要使用的图像的高度
插入图像:
function Draw() { // 获取canvas对象 var canvas=document.getElementById("canvas"); // 获取2d上下文绘图对象 var context=canvas.getContext("2d"); // 使用Image()构造函数创建图像对象 var newImg=new Image(); // 指定图像的文件地址 newImg.src="../images/dadaqianduan.jpg"; newImg.onload=function () { // 左上角开始绘制图像 context.drawImage(newImg, 0, 0); context.drawImage(newImg, 250, 100, 150, 200); context.drawImage(newImg, 90, 80, 100, 100, 0, 0, 120, 120); }}
在Canvas中绘制文字“达达前端”:
// canvas 宽高200<canvas id="canvas" width="200" height="200"></canvas><style type="text/css">canvas { border: 2px solid #ccc;}</style><script> // 获取canvas var canvas=document.getElementById("canvas"); var context=canvas.getContext("2d"); // 设置字体 context.font="98px 黑体"; // 填充 context.fillStyle="#036"; // 文本水平位置 context.textAlign="center"; // 执行绘制 context.fillText("达达前端",100, 120, 200);</script>
两个方法:
绘制矩形边框,使用strokeStyle方法:
// 绘制矩形边框strokeRect(x,y, width, height);
填充矩形区域,使用fillRect()方法:
// 填充矩形区域fillRect(x,y,width,height);
绘制矩形
// 绘制矩形function drawRect() { var canvas=document.getElementById("canvas"); var context=canvas.getContext("2d"); // 描边 context.strokeStyle="#000"; // 线条宽度 context.lineWidth=1; // 矩形边框 context.strokeRect(50,50, 150, 100); // 填充 context.fillStyle="#f90"; // 矩形 context.fillRect(50,50,150,100);}window.addEventListener("load",DrawRect,true);
使用clearRect方法,可以擦除指定的矩形区域:
// 擦除指定的矩形区域context.clearRect(x,y,width,height)
在实际开发中,画布是默认300*150的大小。
示例:
// 为画布设置边框canvas { border: 1px solid #ccc;}// 准备画布,默认是300*150// 设置画布的大小<canvas width="1200" height="800"></canvas>// 准备绘制工具<script>// 获取元素var myCanvas=document.querySelector('canvas');// 获取上下文,绘制工具箱var ctx=myCanvas.getContext('2d');// 移动画笔ctx.moveTo(100,100);// 绘制直线,轨迹ctx.lineTo(200,100);// 描边ctx.stroke()
向 HTML5 页面添加 canvas 元素
// 规定元素的 id、宽度和高度<canvas id="myCanvas" width="200" height="100"></canvas>
图形绘制
需要理解些概念:
设置样式
矩形绘制
圆弧绘制
绘制文本
绘制图片
坐标变换
创建绘图路径
使用方法:beginPath()和closePath(),分别表示开始一个新的路径和关闭当前的路径
closePath方法关闭当前路径
绘制网格,网格大小
var grid=10;// 画多少条x轴方向的线,横向的条数,画布的高度var canvasHeight=myCanvas.heightvar canvasWidth=myCanvas.width// 画布宽高ctx.canvas.widthctx.canvas.height// 网格大小var gridSize=10;var canvasHeight=ctx.canvas.height;var xLineTotal=canvasHeight / gridSize// 总线条var xLineTotal=Math.floor(canvasHeight / gridSize);for (var i=0; i<=xLineTotal; i++) { ctx.beginPath(); ctx.moveTo(0, i*gridSize-0.5); ctx.lineTo(canvasWidth, i*gridSize-0.5); ctx.strokeStyle='#eee'; ctx.stroke();}// 画多少条y轴方向的线var yLineTotal=canvasWidth / gridSizevar yLineTotal=Math.floor(canvasWidth / gridSize);for (var i=0; i <=yLineTotal; i++) { ctx.beginPath(); ctx.moveTo(i*gridSize-0.5,0); ctx.lineTo(i*gridSize-0.5,canvasHeight); ctx.strokeStyle='#eee'; ctx.stroke();}
绘制坐标系,确定圆点,确定离画布旁边的距离,确定坐标轴的长度,确定箭头的大小,绘制箭头填充。
// 绘制坐标系var space=20;var arrowSize=10;// 画布宽高var canvasWidth=ctx.canvas.width;var canvasHeight=ctx.canvas.height;// 坐标系var x0=space;var y0=canvasHeight - space;// 绘制x轴ctx.moveTo(x0,y0);ctx.lineTo(canvasWidth-space, y0);ctx.stroke();// 箭头ctx.lineTo(canvasWidth-space-arrowSize, y0 + arrowSize/2);ctx.lineTo(canvasWidth-space-arrowSize, y0 - arrowSize/2);ctx.lineTo(canvasWidth-space, y0);ctx.fill();ctx.stroke();// 绘制y轴ctx.beginPath();ctx.moveTo(x0, y0);ctx.lineTo(space, space);ctx.stroke();// 箭头ctx.lineTo(space+space-arrowSize/2, space + arrowSize);ctx.lineTo(space-space-arrowSize/2, space - arrowSize);ctx.lineTo(space, space);ctx.fill();ctx.stroke();// 绘制点var coordinate={ x: 146, y: 356}// 点尺寸var dottedSize=6;ctx.moveTo(coordinate.x - dottedSize/2, coordinate.y - dottedSize/2);ctx.lineTo(coordinate.x + dottedSize/2, coordinate.y - dottedSize/2);ctx.lineTo(coordinate.x + dottedSize/2, coordinate.y + dottedSize/2);ctx.lineTo(coordinate.x - dottedSize/2, coordinate.y + dottedSize/2);ctx.closePath();ctx.fill();
arc方法和rect方法
arc创建一个圆形,rect创建一个矩形,最后调用stroke()方法和fill()方法
// 圆形context.arc(100,100,30,0,Math.PI*2,true);
使用beginPath()方法可以新创建一个子路径,closePath()方法用来闭合路径的。
绘制两条直线
function DrawLine() { var canvas=document.getElementById("canvas"); var context=canvas.getContext("2d"); // 创建绘制过程 context.beginPath(); context.moveTo(50,50); context.lineTo(120,120); context.lineTo(120,60); context.closePath(); context.strokeStyle="#000"; // 执行绘制 context.stroke();}
如果不用beginPath()方法,绘制图形时不再创建子路径,第一次的图形在执行过程中会被绘制填充两次。
图形组合
属性 globalCompositeOperation 设置如何在画布上组合颜色
12中组合类型:
值说明copy只绘制新图形,删除其他所有内容darker在图形重叠的地方,颜色由两个颜色值相减后决定destination-atop已有的内容只在它和新的图形重叠的地方保留,新图形绘制在内容后destination-in在新图形和已有画布重叠的地方,已有内容都保留,所有其他内容成为透明destination-out在新图形和已有内容不重叠的地方,已有内容保留所有其他内容成为透明destination-over新图形绘制于已有内容的后面lighter在图形重叠的地方,颜色由两种颜色值的叠加值来决定source-atop只在新图形和已有内容重叠的地方才绘制新图形source-in在新图形和已有内容重叠的地方,新图形才会被绘制,所有其他内容成为透明source-out只在和已有图形不重叠的地方绘制新图形source-over新图形绘制于已有图形的顶部xor在重置和正常绘制的其他地方,图形都成为透明
绘制曲线
// 圆形,曲线arc(x, y, radius, startAngle, endAngle, counterclockwise);
<style>// 画布背景颜色#canvas { background: #000;}</style>// 画布宽度400<canvas id="canvas" width="400" height="400"><script> var canvas=document.getElementById('canvas'); var context=canvas.getContext('2d') // 开始 context.beginPath(); // 绘制圆形 context.arc(100, 100, 50, 0, Math.PI*2, true); // 关闭 context.closePath(); // 填充颜色 context.fillStyle='rgb(255,255,255)'; context.fill();</script>
如果使用css设置宽高,画布会按照300*150的比例进行缩放,将300*150的页面显示在400*400的容器中。
// 设置画布宽度var cx=canvas.width=400;var cy=canvas.height=400;
使用js动态设置宽高。
建议使用HTML中的width和height,或者js动态设置宽高
创建一个canvas标签,第一步:
// 获取这个canvas的上下文对象var canvas=document.getElementById('canvas');var context=canvas.getContext('2d');
方法:
辅助线绘制弧线:arcTo() 方法
语法:
// 辅助线绘制弧线arcTo(x1, y1, x2, y2, radius)
arcTo()方法绘制一条弧线
代码:
// 绘制一条弧线function draw() { var canvas=document.getElementById('canvas'); var context=canvas.getContext('2d'); // 开始绘制 context.beginPath(); // 移动点 context.moveTo(80, 120); // 绘制线条 context.lineTo(150, 60); context.lineTo(180, 130); // 描边 context.strokeStyle="rgba(0,0,0,0.4)"; context.lineWidth=2; context.stroke(); context.beginPath(); context.moveTo(80,120); context.arcTo(150,60,180,130,50); context.strolkeStyle="rgba(255,135,0,1)"; context.stroke();}
quadraticCurveTo()方法:
quadraticCurveTo(cpX, cpY, x, y);// cpX, cpY描述了控制点的坐标,x, y描述了曲线的终点坐标
bezierCurveTo()方法:它是应用于二维图形应用程序的数学曲线。
bezierCurveTo(cp1X, cp1Y, cp2X, cp2Y, x, y);// cp1X, cp1Y 表示第一个控制点的坐标 // cp2X, cp2Y 表示第二个控制点的坐标 // x, y表示曲线的终点坐标
绘制曲线:
function draw() { // 绘制曲线 var canvas=document..getElementById('canvas'); var context=canvas.getContext('2d'); // 开始绘制 context.beginPath(); // 移动 context.moveTo(100,180); // 连线 context.lineTo(110,80); context.moveTo(260,100); context.lineTo(300,200); // 描边 context.strokeStyle="rgba(0,0,0,0.4)"; // 设置宽度 context.lineWidth=3; context.stroke(); context.beginPath(); context.moveTo(100,180); // 绘制贝济埃曲线 context.bezierCurveTo(110,80,260,100,300,200); // 设置宽度 context.lineWidth=3; context.strokeStyle="rgba(255,135,0,1)"; context.stroke();}
var canvas=document.getElementById('canvas');var context=canvas.getContext('2d');// 画布宽度200var canX=canvas.width=200var canY=canvas.height=200;// 开始绘制context.beginPath();// 四分之一圆context.arc(100, 100, 50, 0, Math.PI*0.5, false);context.strokeStyle="white"context.stroke();context.beginPath();context.lineTo(200, 200);context.lineTo(200, 100);context.lineTo(100,50);context.strokeStyle='#fff';context.stroke();
设置阴影,shadowBlur -context.shadowBlur=20
gradient.addColorStop(stop,color)
使用三种方法插入图像
function draw() { var canvas=document.getElementById('canvas'); var context=canvas.getContext('2d'); // image实例 var newImg=new Image(); newImg.src='../images/dada.jpg' // 指定图像的文件地址 newImg.onload=function(){ // 绘图 context.drawImage(newImg, 0, 0); context.drawImage(newImg, 250,100, 150,200); context.drawImage(newImg, 90,80,100,100,0,0,120,120); }}
在插入图像之前,需要考虑图像加载的时间,如果图像没加载完成就已经执行drawImage()方法,就不会显示任何图片。
提供了两种渐变的创建的方法:
// 创建线性渐变createLinearGradient()方法// 创建径向渐变createRadialGradient()方法
设置渐变颜色和过渡方式
语法如下:
addColorStop(offset, color);
function draw() { var canvas=document.getElementById('canvas') var context=canvas.getContext('2d') // 创建渐变对象,线性渐变 var grd=context.createLinearGradient(0,0,300,0) // 设置渐变颜色 grd.addColorStop(0, '#xxx'); // 设置颜色 grd.addColorStop(1, '#xxx'); // 设置颜色 // 将填充样式设置为线性渐变对象 context.fillStyle=grd; context.fillRect(0,0,300,80);}
function draw() { var canvas=document.getElementById('canvas') var context=canvas.getContext('2d') // 径向渐变 var grd=context.createRadialGradient(50,50,0,100,100,90); // 设置渐变颜色以及方式 grd.addColorStop(0,'#xxx'); grd.addColorStop(1,'#xxx'); context.fillStyle=grd; context.beginPath(); // 圆形 context.arc(100,100,90,0,Math.PI*2,true); context.fill();}
线帽属性:lineCap,表示指定线条的末端如何绘制值:lineCap: butt, round, square,当线条具有一定的宽度才能表现出来。
butt// 定义了线段没有线帽round// 定义了线段的末端为一个半圆形的线帽square// 定义了线段的末端为一个矩形的线帽
线条的连接属性lineJoin,用于两条线条到的连接方式:
miter两条线段的外边缘一直延伸到它们相交,属性miterLimit是用来描述如何绘制两条线段的交点,是表示延伸长度和线条长度的比值。
默认为10,只有miter使用时有效
lineJoin=[value];round // 两条线段的外边缘应该和一个填充的弧结合bevel // 两条线段的外边缘应该和一个填充的三角形相交
语法如下:
createPattern(image, repetitionStyle)
function draw() { var canvas=document.getElementById('canvas') var context=canvas.getContext('2d') var img=new Image(); // 使用Image()构造函数创建图像对象 img.src='../images/xxx' // 指定图像的文件地址 img.onload=function() { // 绘图模式 var ptrn=context.createPattern(img, 'repeat'); // 填充样式 context.fillStyle=ptrn; // 填充矩形 context.fillReat(0,0,500,200); }}
移动变化:
// 移动translate(dx,dy);// 绘制function draw() { var canvas=document.getElementById('canvas') var context=canvas.getContext('2d') // 设置移动偏移量 context.translate(200, 200); // 绘制一个圆形 ArcFace(context);}// 绘制一个圆形function ArcFace(context) { // 绘制一个圆形边框 context.beginPath(); // 绘制圆形 context.arc(0,0,90,0,Math.PI*2,true); // 线宽 context.lineWidth=5; // 描边 context.strokeStyle='#f90'; context.stroke(); // 绘制 context.beginPath(); context.moveTo(-30, -30); context.lineTo(-30, -20); context.moveTo(30, -30); context.lineTo(30, -20); context.moveTo(-20, 30); // 曲线 context.bezierCurveTo(-20, 44, 20, 30, 30, 20); context.strokeStyle='#000'; context.lineWidth=10; context.lineCap='round'; // 笑脸 context.stroke();}
缩放变换,语法如下:
scale(sx, sy);// sx为水平方向上的缩放因子,sy为垂直方向上的缩放因子
// 示例function draw() { var canvas=document.getElementById('canvas') var context=canvas.getContent('2d') // 移动 context.translate(200,200); // 缩放 context.scale(0.5,0.5); ArcFace(context);}
旋转变换:
rotate(angle)
// 旋转例子function draw() { var canvas=document.getElementById('canvas'); var context=canvas.getContext('2d') context.translate(200,200); // 旋转 context.rotate(Math.PI/6); context.scale(0.5, 0.5) ArcFace(context)}
矩形变形,语法如下:
transform(m1x,m1y,m2x,m2y,dx,dy); // 移动,缩放,旋转1. 移动translate (dx, dy)2. 缩放scale (sx,sy)3. 旋转rotate (A)
*请认真填写需求信息,我们会在24小时内与您取得联系。