网页展现的更快,官方说法叫做首屏绘制,First Paint 或者简称 FP,直白的说法叫做白屏时间,就是从输入 URL 到真的看到内容(不必可交互,那个叫 TTI, Time to Interactive)之间经历的时间。当然这个时间越短越好。
但这里要注意,和首屏相关的除了 FP 还有两个指标,分别称为 FCP (First Contentful Paint,页面有效内容的绘制) 和 FMP (First Meaningful Paint,页面有意义的内容绘制)。虽然这几个概念可能会让我们绕晕,但我们只需要了解一点:首屏时间 FP 并不要求内容是真实的,有效的,有意义的,可交互的。换言之,随便 给用户看点啥都行。
这就是本文标题的玄机了:“看起来”。是的,只是看起来更快,实际上还是那样。所以本文并不讨论性能优化,讨论的是一个投机取巧的小伎俩,但的确能够实实在在的提升体验。打个比方,性能优化是修炼内功,提升你本身的各项机能;而本文接下来要讨论的是一些招式,能让你在第一时间就唬住对手。
这所谓的招式就是我接下来要谈的内容,学名骨架屏,也叫 Skeleton。你可能没听过这个名字,但你不可能没见过它。
骨架屏长什么样
这种应该是最常见的形式,使用各种形状的灰色矩形来模拟图片和文字。有些 APP 也会使用圆形,但重点都是和实际内容结构近似,不能差距太大。
如果追求效果,还可以在色块表面添加动画(如波纹),显示出一种动态的效果,算是致敬 Loading 了。
在图片居多的站点,这将会是一个很好的体验,因为图片通常加载较慢。如上图演示中的占位图片采用了低像素的图片,即大体配色和变化是和实际内容一致的。
如果无法生成这样的低像素图片,稍微降级的方案是通过算法获取图片的主体颜色,使用纯色块占位。
再退一级,还可以使用全站相同的站位图片,或者直接一个统一颜色的色块。虽说效果肯定不如上面两种,但也聊胜于无。
骨架屏完全是自定义的,想做成什么样全凭你的想象。你想做圆形的,三角形的,立体的都可以,但“占位”决定了它的特性:它不能太复杂,必须第一时间,最快展现出来。
骨架屏有哪些优势
大体来说,骨架屏的优势在于:
1、在页面加载初期预先渲染内容,提升感官上的体验。
2、一般情况骨架屏和实际内容的结构是类似的,因此之后的切换不会过于突兀。这点和传统的 Loading 动图不同,可以认为是其升级版。
3、只需要简单的 CSS 支持 (涉及图片懒加载可能还需要 JS ),不要求 HTTPS 协议,没有额外的学习和维护成本。
4、如果页面采用组件化开发,每个组件可以根据自身状态定义自身的骨架屏及其切换时机,同时维持了组件之间的独立性。
骨架屏能用在哪里
现在的 WEB 站点,大致有两种渲染模式:
前端渲染
由于最近几年 Angular/React/Vue 的相继推出和流行,前端渲染开始占据主导。这种模式的应用也叫单页应用(SPA, Single Page Application)。
前端渲染的模式是服务器(多为静态服务器)返回一个固定的 HTML。通常这个 HTML 包含一个空的容器节点,没有其他内容。之后内部包含的 JS 包含路由管理,页面渲染,页面切换,绑定事件等等逻辑,所以称之为前端渲染。
因为前端要管理的事情很多,所以 JS 通常很大很复杂,执行起来也要花较多的时间。在 JS 渲染出实际内容之前,骨架屏就是一个很好的替补队员。
后端渲染
在这波前端渲染流行之前,早期的传统网站采用的模式叫做后端渲染,即服务器直接返回网站的 HTML 页面,已经包含首页的全部(或绝大部分) DOM 元素。其中包含的 JS 的作用大多是绑定事件,定义用户交互后的行为等。少量会额外添加/修改一些 DOM,但无碍大局。
此外,前端渲染的模式存在 SEO 不友好的问题,因为它返回的 HTML 是一个空的容器。如果搜索引擎没有执行 JS 的能力(称为 Deep Render),那它就不知道你的站点究竟是什么内容,自然也就无法把站点排到搜索结果中去。这对于绝大部分站点来说是不可接受的,于是前端框架又相继推出了服务端渲染(简称 SSR, Server Side Rendering)模式。这个模式和传统网站很接近,在于返回的 HTML 也是包含所有的 DOM,而非前端渲染。而前端 JS 除了绑定事件之外,还会多做一个事情叫做“激活”(hydration),这里就不再赘述了。
不论是传统模式还是 SSR,只要是后端渲染,就不需要骨架屏。因为页面的内容直接存在于 HTML,所以并没有骨架屏出场的余地。
骨架屏怎么用
讨论了一波背景,我们来看如何使用。首先先无视具体的实现细节,先看思路。
实现思路
大体分为几个步骤:
<html> <head> <style> .skeleton-wrapper { // styles } </style> <!-- 声明 meta 或者引入其他 CSS --> </head> <body> <div id="app"> <div class="skeleton-wrapper"> <img src="data:image/svg+xml;base64,XXXXXX"> </div> </div> <!-- 引用 JS --> </body> </html>
let app = new Vue({...}) let container = document.querySelector('#app') if (container) { container.innerHTML = '' } app.$mount(container)
仅此两步,并不牵涉多么复杂的机制和高端的 API,因此非常容易应用,赶快用起来!
示例
我编写了一个示例,用于快速展现骨架屏的效果,代码在此。
代码的三个文件各司其职,配合上面的实现思路,应该还是很好理解的。可以在 这里 查看效果。
因为这个示例的逻辑太过简单,而实际的前端渲染框架复杂得多,包含的功能也不单纯是渲染,还有状态管理,路由管理,虚拟 DOM 等等,所以文件大小和执行时间都更大更长。我们在查看例子的时候,把网络调成 "Fast 3G" 或者 "Slow 3G" 能够稍微真实一些。
但匪夷所思的是,对着这个地址刷新试几次,我也基本看不到骨架屏(骨架屏的内容是一个居中的蓝色方形图片,外加一条白色横线反复侧滑的高亮动画)。是我们的实现思路有问题吗?
浏览器的奥秘:减少重排
为了排除肉眼的遗漏和干扰,我们用 Chrome Dev Tools 的 Performance 工具来记录刚才发生了什么,截图如下:(截图时的网络设置为 "Fast 3G")
我们可以很明显地看到 3 个时间点:
1、HTML 加载完成了。浏览器在解析 HTML 的同时,发现了它需要引用的 2 个外部资源 index.js 和 index.css,于是发送网络请求去获取。
2、获取成功后,执行 JS 并注册 CSS 的规则。
3、JS 一执行,很自然的渲染出了实际的内容,并应用了样式规则(随机颜色的横条)。
我们的骨架屏呢?按照预想,骨架屏应该出现在 1 和 2 之间,也就是在获取 JS 和 CSS 的同时,就应该渲染骨架屏了。这也是我们当时把骨架屏的 HTML 注入到 index.html, 还把 CSS 从 index.css 中分离出来的良苦用心,然而浏览器并不买账。
这其实和浏览器的渲染顺序有关。
相信大家都整理过行李箱。我们在整理行李箱时,会根据每个行李的大小合理安排,大的和小的配合,填满一层再放上面一层。现在突然有人跑来跟你说,你的电脑不用带了,你要多带两件衣服,你不能带那么多瓶矿泉水。除了想打他之外,为了重新整理行李箱,必然需要把整理好的行李拿出来再重新放。在浏览器中这个过程叫做重排 (reflow),而那个馊主意就是新加载的 CSS。显而易见,重排的开销是很大的。
熟能生巧,箱子理多了,就能想出解决办法。既然每个 CSS 文件加载都可能触发重绘,那我能不能等所有 CSS 加载完了一起渲染呢?正是基于这一点,浏览器会等 HTML 中所有的 CSS 都加载完,注册完,一起应用样式,力求一次排列完成工作,不要反复重排。看起来浏览器的设计者经常出差,因为这是一个很正确的优化思路,但应用在骨架屏上就出了问题。
我们为了尽早展现骨架屏,把骨架屏的样式从 index.css 分离出来。但浏览器不知道,它以为骨架屏的 HTML 还依赖 index.css,所以必须等它加载完。而它加载完之后,render.js 也差不多加载完开始执行了,于是骨架屏的 HTML 又被替换了,自然就看不到了。而且在等待 JS, CSS 加载的时候依然是个白屏,骨架屏的效果大打折扣。
所以我们要做的是告诉浏览器,你放心大胆的先画骨架屏,它和后面的 index.css 是无关的。那怎么告诉它呢?
告诉浏览器先渲染骨架屏
我们在引用 CSS 时,会使用 <link rel="stylesheet" href="xxxx> 这样的语法。但实际上,浏览器还提供了其他一些机制确保(后续)页面的性能,我们称之为 preload,中文叫预加载。具体来说,使用 <link rel="preload" href="xxxx">,提前把后续要使用的资源先声明一下。在浏览器空闲的时候会提前加载并放入缓存。之后再使用就可以节省一个网络请求。
这看似无关的技术,在这里将起到很大的作用,因为 预加载的资源是不会影响当前页面的。
我们可以通过这种方式,告诉浏览器:先不要管 index.css,直接画骨架屏。之后 index.css加载回来之后,再应用这个样式。具体来说代码如下:
<link rel="preload" href="index.css" as="style" onload="this.rel='stylesheet'">
方法的核心是通过改变 rel 可以让浏览器重新界定 <link> 标签的角色,从预加载变成当页样式。(另外也有文章采用修改 media 的方法,但浏览器支持度较低,这里不作展开了。我把文章列在最后了)这样的话,浏览器在 CSS 尚未获取完成时,会先渲染骨架屏(因为此时的 CSS 还是 preload,也就是后续使用的,并不妨碍当前页面)。而当 CSS 加载完成并修改了自己的 rel之后,浏览器重新应用样式,目的达成。
不得不考虑的注意点
事实上,并不是把 rel="stylesheet" 改成 rel="preload" 就完事儿了。在真正应用到生产环境之前,我们还有很多事情要考虑。
兼容性考虑
首先,在 <link> 内部我们使用了 onload,也就是使用了 JS。为了应对用户的浏览器没有开启脚本功能的情况,我们需要添加一个 fallback。(不过这点对于单页应用来说可能也无所谓,因为如果没有脚本,那页面实际内容也渲染不出来的)
<noscript><link rel="stylesheet" href="index.css"></noscript>
其次,rel="preload" 并不是没有兼容性问题。对于不支持 preload 的浏览器,我们可以添加一些 polyfill 代码(来使所有浏览器获得一致的效果。
<script> /*! loadCSS rel=preload polyfill. [c]2017 Filament Group, Inc. MIT License */ (function(){ ... }()); </script>
polyfill 的压缩代码可以参见 Lavas 的 SPA 模板第 29 行。
加载顺序
不同于传统页面,我们的实际 DOM 是通过 render.js 生成的。所以如果 JS 先于 CSS 执行,那将会发生跳动。(因为先渲染了实际内容却没有样式,而后样式加载,页面出现很明显的变化)所以这里我们需要严格控制 CSS 早于渲染。
<link rel="preload" href="index.css" as="style" onload="this.rel='stylesheet';window.STYLE_READY=true;window.mountApp && window.mountApp()">
JS 对外暴露一个 mountApp 方法用于渲染页面(其实是模拟 Vue 的 mount)
// render.js function mountApp() { // 方法内部就是把实际内容添加到 <body> 上面 } // 本来直接调用方法完成渲染 // mountApp() // 改成挂到 window 由 CSS 来调用 window.mountApp = mountApp() // 如果 JS 晚于 CSS 加载完成,那直接执行渲染。 if (window.STYLE_READY) { mountApp() }
如果 CSS 更快加载完成,那么通过设置 window.STYLE_READY 允许 JS 加载完成后直接执行;而如果 JS 更快,则先不自己执行,而是把机会留给 CSS 的 onload。
清空 onload
loadCSS 的开发者提出,某些浏览器会在 rel 改变时重新出发 onload,导致后面的逻辑走了两次。为了消除这个影响,我们再在 onload 里面添加一句 this.onload=null。
最终的 CSS 引用方式
<link rel="preload" href="index.css" as="style" onload="this.onload=null;this.rel='stylesheet';window.STYLE_READY=true;window.mountApp && window.mountApp()"> <!-- 为了方便阅读,折行重复一遍 --> <!-- this.onload=null --> <!-- this.rel='stylesheet' --> <!-- window.STYLE_READY=true --> <!-- window.mountApp && window.mountApp() -->
修改后的效果
修改后的代码在 这里,访问地址在 这里。(为了简便,我省去了处理兼容性的代码,即 <noscript> 和 preload polyfill)
Performance 截图如下:(依然采用了 "Fast 3G" 的网络设置)
这次在 render.js 和 index.css 还在加载的时候页面已经呈现出骨架屏的内容,实际肉眼也可以观测到。在截图的情况下,骨架屏的展现大约持续了 300ms,占据整个网络请求的大约一半时间。
至于说为什么不是 HTML 加载完成立马展现骨架屏,而是还要等大约 300ms 才展现,从图上看是浏览器 ParseHTML 所花费的时间,可能在 Dev Tools 打开的情况下计算资源有限,不过可优化空间已经不大。(可能简化骨架屏的结构能起一些作用吧)
多骨架屏的支持
一般来说一个站点的所有页面不太可能是同一种展示类型。例如说首页和内部页面就展示风格而言会很有区别,另外例如列表页和搜索页比较接近(可能都有列表展示),但和详情页(可能是商品,服务,个人信息,博客文章等等)就会很不相同。但单页应用的 index.html 只有一个,所有的变化都源自前端渲染框架在容器节点内部进行改变。所以直接将骨架屏注入到 index.html中会导致所有的页面都用同一个骨架屏,那就很难达成“和实际内容结构类似”的目标了,骨架屏就退化为 Loading 了。
为了要支持多种骨架屏,我们需要在 index.html 里面进行判断逻辑(独立于主体 JS 之外),具体来说:
1、把所有种类的骨架屏的 HTML 和样式全部写入 index.html
2、在 index.html 底下新增内联的脚本 <script>,根据当前路由判断应该展示哪一个骨架屏
这样会导致 index.html 体积变大一点,但整体感觉依然是收益大于付出,我认为是值得的。
后记
这个优化点最早由我的前同事 xiaop 同学 在开发 Lavas 的 SPA 模板中发现并完成的,Issue 记录在此。我在他的基础上,做了一个分离 Lavas 和 Vue 环境并且更直白的例子,让截图也尽可能易于理解,方便阅读。在此非常感谢他的工作!
另外骨架屏的编写我全部采用的是纯粹的手写 HTML 和 CSS,不止展现逻辑,包括开发流程也是独立于单页应用其他常规页面的。当然这可能给开发者带来一点不便,所以这时候需要推出 xiaop 同学的利器:vue-skeleton-webpack-plugin https://github.com/lavas-project/vue-skeleton-webpack-plugin。它的作用是把骨架屏本身也当成一个 Vue 组件,配上单独的路由规则来统一在 Vue 项目中的开发体验,最后使用 webpack 在打包构建的时候加以区分并注入,对于使用 Vue + webpack 开发的同学来说可以一试。
参考文章
转自作者作者:小蘑菇小哥https://zhuanlan.zhihu.com/p/48601348
素的显示与隐藏
使用CSS让元素不可见的方法很多,剪裁、定位到屏幕外、透明度变化等都是可以的。虽然它们都是肉眼看不见,但背后却在多个维度上都有差别
下面是总结的一些比较好的隐藏实践,大家一起来根据实际开发场景来选择合适的使用
比较好的隐藏实践
不占空间,资源可以加载,DOM可访问 使用display:none
不占空间,隐藏显示时有transition效果
占空间,不能点击 visibility: hidden
不占空间,不能点击,键盘能访问 clip裁切
占空间,不能点击,键盘能访问 relative
占空间,可以点击 opacity
隐藏文字 使用text-indent
根据实际的隐藏场景选择合适的隐藏方法,这里就不再多说了,接着往下看吧
display与元素的显隐
我们都知道display如果值为none,则该元素以及所有后代元素都隐藏,反之如果值是非none的情况,则都为显示了
display可以说是web显隐交互中出场频率最高的一种隐藏方式,是真正意义上的隐藏,干净利落,不留痕迹
none做到了无法点击、无法使用屏幕阅读器等辅助设备访问,不占空间,其实不仅仅是这样,更应该知道的是
me: 我有酒,那么别说你没有故事
我知道display:none你才不是一个没有故事的女同学
display: none的元素的background-image图片根据不同浏览器的情况加载情况不一
在Firefox浏览器下,display:none的background-image图片不加载,包括父元素display:none也是如此在Chrome和Safari浏览器,则根据父元素是否是否为none来影响图片加载情况,父元素带有display:none,图片不加载。
父元素不带有display:none,而自身有背景图元素带的话,那也照样加载
3.在IE浏览器下,无论怎么搞都会请求图片资源,就是这么任性
因此,在实际开发的时候,例如头图轮播切换效果
那些默认需要隐藏的图片作为背景图藏在display:none元素的子元素上,这样的细小改动就可以明显提升页面的加载体验,也是非常实用的小技巧
whatever
上面说的兴致盎然,但实际中不可能全部都是背景图去加载图片资源的
还有另外一个好朋友,img元素,然并卵的是,上面说了一大堆加载不加载的情况,对img来说没个鸟用,人家不管你none不none的,依旧带着勇闯天涯的气概去请求着资源
活久见
都说display:none做事最纯粹,最干净,不能被点击,触碰到,然而下面这种情况又是什么鬼?
出来解释解释,我们都是文明人是绝对不会动武的!
隐藏的按钮会触发click,触发表单提交,此现象出现在时髦的浏览器中(IE9+,现代标准浏览器中)
既然有这种例外情况那加了display:none的意义又是什么呢?
很多都是纯天然的
HTML中有很多标签和属性天然自带display:none
HTML5中新增了hidden这个布尔属性,可以让元素天生隐藏起来
既然说到了visibility了,那么就赶紧邀请visibility闪亮登场吧
visibility与元素的显隐
visibility要为自己正名,不仅仅是保留空间这么简单
看点多多:
继承性(最有意思的一个特点,不是我说的)
2. 与css计数器
visibility:hidden虽然让元素不可见了,但是不影响其计数效果,不会重新计算结果
3. 与transition
设置了visibility:hidden的元素,可以很好的展现transition过渡效果
这是因为transition支持的css属性中有visibility(果然是兄弟),而并没有display属性
4.与JS
visibility:hidden除了对transition友好外,对js来说也很友好
在实际开发中,需要对隐藏元素进行尺寸和位置的获取,来实现布局精确定位的交互
此时,就建议使用visibility:hidden
好了以上内容要告一段落了,我们继续开始新的征程吧,哈哈
用户界面样式
用户界面样式指的是CSS世界中用来帮助用户进行界面交互的一些CSS样式,主要有outline和cursor等属性
和border形似的outline属性
outline表示元素的轮廓,语法也和border一样,分为宽度、类型和颜色三个值
样式表示上相同,但是设计的初衷却是不太相同的,这一点天地日月可鉴
outline是一个和用户体验密切相关的属性,与focus状态以及键盘访问密切相关
对于按钮或链接,通常的键盘操作是:Tab键按次序不断focus控件元素(链接、按钮、输入框等表单元素),或者focus设置了tabindex的普通元素,然后按Shift+Tab是反向访问
重点来了!
默认状态下,对于处于focus状态的元素,浏览器会通过发光or虚框的形式进行区分和提示,这是友好的用户体验,很有必要,不然用户很难知道自己当前聚焦在了哪个元素上面,会迷失自我
元素如果聚焦到了a链接上,按下回车键就会跳转到相应链接,以上的交互都是基于键盘访问的,这就是为什么outline和键盘访问如此亲密了
不专业的行为
很多时候直接在reset样式的时候,写成如下形式是非常不可取的
这样直接一竿子打死一群鸭子的做法是不对的,更多的时候是因为浏览器内置的focus效果和设计风格格格不入,才需要重置,而且要使用专门的类名
最后再强调一遍:万万不可在全局设置outline: 0 none;
这样的操作会造成键盘访问的时候用户找不到当前焦点,容易产生困扰的,为了大家好,收敛一下吧
下面来点干货: 在实际开发中,有时候需要让普通元素代替表单控件元素有outline效果
举个栗子:submit按钮来完成UI设计是非常麻烦的,所以使用label元素来移花接木,通过for属性和这些原生的表单控件相关联
真正的不占据空间的outline及其应用
outline是一个真正意义上不占任何空间的属性,Amazing
头像剪裁的矩形镂空效果
先来看个效果图
上图就是矩形镂空效果,那么下面直接上代码,满满的干货
用一个大大的outline来实现周围半透明的黑色遮罩,因为outline无论设置多么多么大,都不会占据空间影响布局,至于超出的部分,直接给父元素设置一个overflow:hidden就搞定了 注意:
自动填满屏幕剩余空间的应用技巧
开发中很多时候,由于页面内容不够多,导致底部footer会出现尴尬的剩余空间,解决方法往往也有很多种,在此我们还是依然利用outline的功能来完美实现一下
关键的css就是设置一个超大轮廓范围的outline属性,如给个9999px,保证无论屏幕多高,轮廓颜色都能覆盖
值得注意的是,outline无法指定方位,它是直接向四周发散的,所以需要配合clip剪裁来进行处理,以左边和上边为边界进行裁剪
光标属性
光标属性cursor我们真的是最熟悉的陌生人啊
为什么这么说呢,因为在众多的属性值面前,我们似乎只用到了pointer(手形)(最常用的,没有之一),move(移动),default(系统默认)这几样
在cursor的世界里,远比我们想象的要丰富很多,下面按照功能特性来对其进行分类吧
琳琅满目的cursor属性值
友情不友情的小提示:☆(表示常用)
链接和状态
cursor: progress; 进行中
选择
拖拽都是CSS3新增的光标类型
以上内容就介绍完了用户界面样式的全部内容了,还有最后一章的冷知识,大家不要方,继续看下去,了解一下,了解一下,了解一下
流向的改变
说出来你可能不信,direction可以改变水平流向,尽管知道或者使用过的人少之又少,但并不妨碍它的发光发热
而且属性简单好记,值少,兼容极好ie6支持,可以来挖掘一下它的神奇功效
direction
仅仅两个值:
direction: rtl;
当然看到这里你可能会感觉,这些说起来都没什么鸟用,因为大招是不轻易放出的,而真正有用的地方在于改变网页布局的时候
direction属性默认有一个特性
可以改变替换元素(img,input,textarea,select)或inline-block/inline-table元素的水平呈现顺序
举个例子:颠倒顺序
再举个例子:
比如制作弹窗组件的时候,确认和取消按钮有的时候会根据用户的使用行为会显示在不同的位置
下面来看看这种特性的表现在实际开发中的作用
windows用户看到的样子
好了,direction的话题就告一段落,接下来介绍最后一个知识了,坚持住,快休息了
writing-mode
改变CSS世界纵横规则的writing-mode,如此强大的功能,居然没有被大家发掘和广发应用起来,实属遗憾了,话不多说,往下看
writing-mode作用及真正需要关注的属性值
writing-mode可以改变排版,变成垂直流,如下图所示
在使用语法上,也是需要记两套的,一套是IE私有属性,一套是CSS3规范属性
CSS3语法:
IE语法:
针对实战版来整理一份writing-mode是这样的
对于垂直排版来说,实际开发是很少会遇到的,不过还是要说说writing-mode带来的改变
水平方向也能margin合并
我们都知道两个相邻的元素垂直的margin会合并,当元素变为垂直流的时候,水平的margin也会合并
普通块元素可以使用margin: auto实现垂直居中
text-align:center实现图片垂直居中(同上实现的效果)
实现全兼容的icon fonts图标旋转效果
老IE下让小图标旋转很麻烦,writing-mode把文档变成垂直流的时候,英文、数字和字符号都天然的转了90°
@font-face的兼容性很好IE5.5就支持了,所以就算是IE6和IE7也没问题
好了,这就是《CSS世界》里最后三章的全部内容了,终于写完了,哈哈,希望大家有收获一些冷知识。
简单说两句
做个个人的小总结吧:
css有很多奇妙的地方,在某些特性当初被设计出来的时候可能只是为了某些图文排版而生
但是我们可以利用它们带来的特性发挥自己的创造力,实现其他很多意想不到的效果,因此,上面所讲述的所有知识点,尽管很多内容都有点奇技淫巧以悦妇孺的过程
但这也给我们开发的过程中,提供了一些很出奇的妙招,值得我们好好学习领悟
感谢个位的观看了,再见了,哈哈
者开源了一个Web思维导图mind-map,最近在优化背景图片效果的时候遇到了一个问题,页面上展示时背景图片是通过css使用background-image渲染的,而导出的时候实际上是绘制到canvas上导出的,那么就会有个问题,css的背景图片支持比较丰富的效果,比如通过background-size设置大小,通过background-position设置位置,通过background-repeat设置重复,但是canvas笔者只找到一个createPattern()方法,且只支持设置重复效果,那么如何在canvas里模拟一定的css背景效果呢,不要走开,接下来一起来试试。
首先要说明的是不会去完美完整100%模拟css的所有效果,因为css太强大了,属性值组合很灵活,且种类非常多,其中单位就很多种,所有只会模拟一些常见的情况,单位也只考虑px和%。
读完本文,你还可以顺便复习一下canvas的drawImage方法,以及css背景设置的几个属性的用法。
总的来说,我们会使用canvas的drawImage()方法来绘制背景图片,先来大致看一下这个方法,这个方法接收的参数比较多:
只有三个参数是必填的。
核心逻辑就是加载图片,然后使用drawImage方法绘制图片,无非是根据各种css的属性和值来计算drawImage的参数,所以可以写出下面的函数基本框架:
const drawBackgroundImageToCanvas = (
ctx,// canvas绘图上下文
width,// canvas宽度
height,// canvas高度
img,// 图片url
{ backgroundSize, backgroundPosition, backgroundRepeat }// css样式,只模拟这三种
) => {
// canvas的宽高比
let canvasRatio = width / height
// 加载图片
let image = new Image()
image.src = img
image.onload = () => {
// 图片的宽高及宽高比
let imgWidth = image.width
let imgHeight = image.height
let imageRatio = imgWidth / imgHeight
// 绘制图片
// drawImage方法的参数值
let drawOpt = {
sx: 0,
sy: 0,
swidth: imgWidth,// 默认绘制完整图片
sheight: imgHeight,
x: 0,
y: 0,
width: imgWidth,// 默认不缩放图片
height: imgHeight
}
// 根据css属性和值计算...
// 绘制图片
ctx.drawImage(image, drawOpt.sx, drawOpt.sy, drawOpt.swidth, drawOpt.sheight, drawOpt.x, drawOpt.y, drawOpt.width, drawOpt.height)
}
}
接下来看几个工具函数。
// 将以空格分隔的字符串值转换成成数字/单位/值数组
const getNumberValueFromStr = value => {
let arr = String(value).split(/\s+/)
return arr.map(item => {
if (/^[\d.]+/.test(item)) {
// 数字+单位
let res = /^([\d.]+)(.*)$/.exec(item)
return [Number(res[1]), res[2]]
} else {
// 单个值
return item
}
})
}
css的属性值为字符串或数字类型,比如100px 100% auto,不方便直接使用,所以转换成[[100, 'px'], [100, '%'], 'auto']形式。
// 缩放宽度
const zoomWidth = (ratio, height) => {
// w / height = ratio
return ratio * height
}
// 缩放高度
const zoomHeight = (ratio, width) => {
// width / h = ratio
return width / ratio
}
根据原比例和新的宽度或高度,计算缩放后的宽度或高度。
默认background-repeat的值为repeat,我们先不考虑重复的情况,所以先把它设置成no-repeat。
background-size 属性用于设置背景图片的大小,可以接受四种类型的值,依次来模拟一下。
设置背景图片的高度和宽度。第一个值设置宽度,第二个值设置高度。如果只给出一个值,第二个默认为 auto(自动)。
css样式如下:
.cssBox {
background-image: url('/1.jpg');
background-repeat: no-repeat;
background-size: 300px;
}
只设置一个值,那么代表背景图片显示的实际宽度,高度没有设置,那么会根据图片的长宽比自动缩放,效果如下:
在canvas中模拟很简单,需要传给drawImage方法四个参数:img、x、y、width、height,img代表图片,x、y代表在画布上放置图片的位置,没有特殊设置,显然就是0、0,width、height代表将图片缩放到指定大小,如果background-size只传了一个值,那么width直接设置成这个值,而height则根据图片的长宽比进行计算,如果传了两个值,那么分别把两个值传给width、height即可,另外需要对值为auto的进行一下处理,实现如下:
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundSize: '300px'
})
const drawBackgroundImageToCanvas = () =>{
// ...
image.onload = () => {
// ...
// 模拟background-size
handleBackgroundSize({
backgroundSize,
drawOpt,
imageRatio
})
// ...
}
}
// 模拟background-size
const handleBackgroundSize = ({ backgroundSize, drawOpt, imageRatio }) => {
if (backgroundSize) {
// 将值转换成数组
let backgroundSizeValueArr = getNumberValueFromStr(backgroundSize)
// 两个值都为auto,那就相当于不设置
if (backgroundSizeValueArr[0] === 'auto' && backgroundSizeValueArr[1] === 'auto') {
return
}
// 图片宽度
let newNumberWidth = -1
if (backgroundSizeValueArr[0]) {
if (Array.isArray(backgroundSizeValueArr[0])) {
// 数字+单位类型
drawOpt.width = backgroundSizeValueArr[0][0]
newNumberWidth = backgroundSizeValueArr[0][0]
} else if (backgroundSizeValueArr[0] === 'auto') {
// auto类型,那么根据设置的新高度以图片原宽高比进行自适应
if (backgroundSizeValueArr[1]) {
drawOpt.width = zoomWidth(imageRatio, backgroundSizeValueArr[1][0])
}
}
}
// 设置了图片高度
if (backgroundSizeValueArr[1] && Array.isArray(backgroundSizeValueArr[1])) {
// 数字+单位类型
drawOpt.height = backgroundSizeValueArr[1][0]
} else if (newNumberWidth !== -1) {
// 没有设置图片高度或者设置为auto,那么根据设置的新宽度以图片原宽高比进行自适应
drawOpt.height = zoomHeight(imageRatio, newNumberWidth)
}
}
}
效果如下:
设置两个值的效果:
background-size: 300px 400px;
将计算相对于背景定位区域的百分比。第一个值设置宽度百分比,第二个值设置的高度百分比。如果只给出一个值,第二个默认为auto(自动)。比如设置了50% 80%,意思是将图片缩放到背景区域的50%宽度和80%高度。
css样式如下:
.cssBox {
background-image: url('/1.jpg');
background-repeat: no-repeat;
background-size: 50% 80%;
}
实现也很简单,在前面的基础上判断一下单位是否是%,是的话就按照canvas的宽高来计算图片要显示的宽高,第二值没有设置或者为auto,跟之前一样也是根据图片的宽高比来自适应。
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundSize: '50% 80%'
})
handleBackgroundSize({
backgroundSize,
drawOpt,
imageRatio,
canvasWidth: width,// 传参新增canvas的宽高
canvasHeight: height
})
// 模拟background-size
const handleBackgroundSize = ({ backgroundSize, drawOpt, imageRatio, canvasWidth, canvasHeight }) => {
if (backgroundSize) {
// ...
// 图片宽度
let newNumberWidth = -1
if (backgroundSizeValueArr[0]) {
if (Array.isArray(backgroundSizeValueArr[0])) {
// 数字+单位类型
if (backgroundSizeValueArr[0][1] === '%') {
// %单位,则图片显示的高度为画布的百分之多少
drawOpt.width = backgroundSizeValueArr[0][0] / 100 * canvasWidth
newNumberWidth = drawOpt.width
} else {
// 其他都认为是px单位
drawOpt.width = backgroundSizeValueArr[0][0]
newNumberWidth = backgroundSizeValueArr[0][0]
}
} else if (backgroundSizeValueArr[0] === 'auto') {
// auto类型,那么根据设置的新高度以图片原宽高比进行自适应
if (backgroundSizeValueArr[1]) {
if (backgroundSizeValueArr[1][1] === '%') {
// 高度为%单位
drawOpt.width = zoomWidth(imageRatio, backgroundSizeValueArr[1][0] / 100 * canvasHeight)
} else {
// 其他都认为是px单位
drawOpt.width = zoomWidth(imageRatio, backgroundSizeValueArr[1][0])
}
}
}
}
// 设置了图片高度
if (backgroundSizeValueArr[1] && Array.isArray(backgroundSizeValueArr[1])) {
// 数字+单位类型
if (backgroundSizeValueArr[1][1] === '%') {
// 高度为%单位
drawOpt.height = backgroundSizeValueArr[1][0] / 100 * canvasHeight
} else {
// 其他都认为是px单位
drawOpt.height = backgroundSizeValueArr[1][0]
}
} else if (newNumberWidth !== -1) {
// 没有设置图片高度或者设置为auto,那么根据设置的新宽度以图片原宽高比进行自适应
drawOpt.height = zoomHeight(imageRatio, newNumberWidth)
}
}
}
效果如下:
background-size设置为cover代表图片会保持原来的宽高比,并且缩放成将完全覆盖背景定位区域的最小大小,注意,图片不会变形。
css样式如下:
.cssBox {
background-image: url('/3.jpeg');
background-repeat: no-repeat;
background-size: cover;
}
这个实现也很简单,根据图片的宽高比和canvas的宽高比判断,到底是缩放图片的宽度和canvas的宽度一致,还是缩放图片的高度和canvas的高度一致。
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundSize: 'cover'
})
handleBackgroundSize({
backgroundSize,
drawOpt,
imageRatio,
canvasWidth: width,
canvasHeight: height,
canvasRatio// 参数增加canvas的宽高比
})
const handleBackgroundSize = ({
backgroundSize,
drawOpt,
imageRatio,
canvasWidth,
canvasHeight,
canvasRatio
}) => {
// ...
// 值为cover
if (backgroundSizeValueArr[0] === 'cover') {
if (imageRatio > canvasRatio) {
// 图片的宽高比大于canvas的宽高比,那么图片高度缩放到和canvas的高度一致,宽度自适应
drawOpt.height = canvasHeight
drawOpt.width = zoomWidth(imageRatio, canvasHeight)
} else {
// 否则图片宽度缩放到和canvas的宽度一致,高度自适应
drawOpt.width = canvasWidth
drawOpt.height = zoomHeight(imageRatio, canvasWidth)
}
return
}
// ...
}
效果如下:
background-size设置为contain类型表示图片还是会保持原有的宽高比,并且缩放成适合背景定位区域的最大大小,也就是图片会显示完整,但是不一定会铺满背景的水平和垂直两个方向,在某个方向可能会有留白。
css样式:
.cssBox {
background-image: url('/1.jpg');
background-repeat: no-repeat;
background-size: contain;
}
实现刚好和cover类型的实现反过来即可,如果图片的宽高比大于canvas的宽高比,为了让图片显示完全,让图片的宽度和canvas的宽度一致,高度自适应。
const handleBackgroundSize = () => {
// ...
// 值为contain
if (backgroundSizeValueArr[0] === 'contain') {
if (imageRatio > canvasRatio) {
// 图片的宽高比大于canvas的宽高比,那么图片宽度缩放到和canvas的宽度一致,高度自适应
drawOpt.width = canvasWidth
drawOpt.height = zoomHeight(imageRatio, canvasWidth)
} else {
// 否则图片高度缩放到和canvas的高度一致,宽度自适应
drawOpt.height = canvasHeight
drawOpt.width = zoomWidth(imageRatio, canvasHeight)
}
return
}
}
效果如下:
到这里对background-size的模拟就结束了,接下来看看background-position。
先看不设置background-size的情况。
background-position属性用于设置背景图像的起始位置,默认值为0% 0%,它也支持几种不同类型的值,一一来看。
第一个值设置水平位置,第二个值设置垂直位置。左上角是0%0%,右下角是100%100%,如果只设置了一个值,第二个默认为50%,比如设置为50% 60%,意思是将图片的50% 60%位置和背景区域的50% 60%位置进行对齐,又比如50% 50%,代表图片中心点和背景区域中心点重合。
css样式:
.cssBox {
background-image: url('/2.jpg');
background-repeat: no-repeat;
background-position: 50% 50%;
}
实现上我们只需要用到drawImage方法的img、x、y三个参数,图片的宽高不会进行缩放,根据比例分别算出在canvas和图片上对应的距离,他们的差值即为图片在canvas上显示的位置。
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundPosition: '50% 50%'
})
const drawBackgroundImageToCanvas = () => {
// ...
// 模拟background-position
handleBackgroundPosition({
backgroundPosition,
drawOpt,
imgWidth,
imgHeight,
canvasWidth: width,
canvasHeight: height
})
// ...
}
// 模拟background-position
const handleBackgroundPosition = ({
backgroundPosition,
drawOpt,
imgWidth,
imgHeight,
canvasWidth,
canvasHeight
}) => {
if (backgroundPosition) {
// 将值转换成数组
let backgroundPositionValueArr = getNumberValueFromStr(backgroundPosition)
if (Array.isArray(backgroundPositionValueArr[0])) {
if (backgroundPositionValueArr.length === 1) {
// 如果只设置了一个值,第二个默认为50%
backgroundPositionValueArr.push([50, '%'])
}
// 水平位置
if (backgroundPositionValueArr[0][1] === '%') {
// 单位为%
let canvasX = (backgroundPositionValueArr[0][0] / 100) * canvasWidth
let imgX = (backgroundPositionValueArr[0][0] / 100) * imgWidth
// 计算差值
drawOpt.x = canvasX - imgX
}
// 垂直位置
if (backgroundPositionValueArr[1][1] === '%') {
// 单位为%
let canvasY = (backgroundPositionValueArr[1][0] / 100) * canvasHeight
let imgY = (backgroundPositionValueArr[1][0] / 100) * imgHeight
// 计算差值
drawOpt.y = canvasY - imgY
}
}
}
}
效果如下:
第一个值代表水平位置,第二个值代表垂直位置。左上角是0 0。单位可以是px或任何其他css单位,当然,我们只考虑px。如果仅指定了一个值,其他值将是50%。所以你可以混合使用%和px。
css样式:
.cssBox {
background-image: url('/2.jpg');
background-repeat: no-repeat;
background-position: 50px 150px;
}
这个实现更简单,直接把值传给drawImage的x、y参数即可。
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundPosition: '50px 150px'
})
// 模拟background-position
const handleBackgroundPosition = ({}) => {
// ...
// 水平位置
if (backgroundPositionValueArr[0][1] === '%') {
// ...
} else {
// 其他单位默认都为px
drawOpt.x = backgroundPositionValueArr[0][0]
}
// 垂直位置
if (backgroundPositionValueArr[1][1] === '%') {
// ...
} else {
// 其他单位默认都为px
drawOpt.y = backgroundPositionValueArr[1][0]
}
}
也就是通过left、top之类的关键词进行组合,比如:left top、center center、center bottom等。可以看做是特殊的%值,所以我们只要写一个映射将这些关键词对应上百分比数值即可。
.cssBox {
background-image: url('/2.jpg');
background-repeat: no-repeat;
background-position: right bottom;
}
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundPosition: 'right bottom'
})
// 关键词到百分比值的映射
const keyWordToPercentageMap = {
left: 0,
top: 0,
center: 50,
bottom: 100,
right: 100
}
const handleBackgroundPosition = ({}) => {
// ...
// 将关键词转为百分比
backgroundPositionValueArr = backgroundPositionValueArr.map(item => {
if (typeof item === 'string') {
return keyWordToPercentageMap[item] !== undefined
? [keyWordToPercentageMap[item], '%']
: item
}
return item
})
// ...
}
最后我们来看看和background-size组合使用会发生什么情况。
.cssBox {
background-image: url('/2.jpg');
background-repeat: no-repeat;
background-size: cover;
background-position: right bottom;
}
drawBackgroundImageToCanvas(ctx, width, height, this.img, {
backgroundSize: 'cover',
backgroundPosition: 'right bottom'
})
结果如下:
不一致,这是为啥呢,我们来梳理一下,首先在处理background-size会计算出drawImage参数中的width、height,也就是图片在canvas中显示的宽高,而在处理background-position时会用到图片的宽高,但是我们传的还是图片的原始宽高,这样计算出来当然是有问题的,修改一下:
// 模拟background-position
handleBackgroundPosition({
backgroundPosition,
drawOpt,
imgWidth: drawOpt.width,// 改为传计算后的图片的显示宽高
imgHeight: drawOpt.height,
imageRatio,
canvasWidth: width,
canvasHeight: height,
canvasRatio
})
现在再来看看效果:
background-repeat属性用于设置如何平铺对象的background-image属性,默认值为repeat,也就是当图片比背景区域小时默认会向垂直和水平方向重复,另外还有几个可选值:
接下来我们实现一下这几种情况。
首先判断图片的宽高是否都比背景区域大,是的话就不需要平铺,也就不用处理,另外值为no-repeat也不需要做处理:
// 模拟background-repeat
handleBackgroundRepeat({
backgroundRepeat,
drawOpt,
imgWidth: drawOpt.width,
imgHeight: drawOpt.height,
imageRatio,
canvasWidth: width,
canvasHeight: height,
canvasRatio
})
可以看到这里我们传的图片的宽高也是经background-size计算后的图片显示宽高。
// 模拟background-repeat
const handleBackgroundRepeat = ({
backgroundRepeat,
drawOpt,
imgWidth,
imgHeight,
canvasWidth,
canvasHeight,
}) => {
if (backgroundRepeat) {
// 将值转换成数组
let backgroundRepeatValueArr = getNumberValueFromStr(backgroundRepeat)
// 不处理
if (backgroundRepeatValueArr[0] === 'no-repeat' || (imgWidth >= canvasWidth && imgHeight >= canvasHeight)) {
return
}
}
}
接下来增加对repeat-x的支持,当canvas的宽度大于图片的宽度,那么水平平铺进行绘制,绘制会重复调用drawImage方法,所以还需要再传递ctx、image参数给handleBackgroundRepeat方法,另外如果handleBackgroundRepeat方法里进行了绘制,原来的绘制方法就不用再调用了:
// 模拟background-repeat
// 如果在handleBackgroundRepeat里进行了绘制,那么会返回true
let notNeedDraw = handleBackgroundRepeat({
ctx,
image,
...
})
if (!notNeedDraw) {
drawImage(ctx, image, drawOpt)
}
// 根据参数绘制图片
const drawImage = (ctx, image, drawOpt) => {
ctx.drawImage(
image,
drawOpt.sx,
drawOpt.sy,
drawOpt.swidth,
drawOpt.sheight,
drawOpt.x,
drawOpt.y,
drawOpt.width,
drawOpt.height
)
}
将绘制的方法提取成了一个方法,方便复用。
const handleBackgroundRepeat = ({}) => {
// ...
// 水平平铺
if (backgroundRepeatValueArr[0] === 'repeat-x') {
if (canvasWidth > imgWidth) {
let x = 0
while (x < canvasWidth) {
drawImage(ctx, image, {
...drawOpt,
x
})
x += imgWidth
}
return true
}
}
// ...
}
每次更新图片的放置位置x参数,直到超出canvas的宽度。
对repeat-y的处理也是类似的:
const handleBackgroundRepeat = ({}) => {
// ...
// 垂直平铺
if (backgroundRepeatValueArr[0] === 'repeat-y') {
if (canvasHeight > imgHeight) {
let y = 0
while (y < canvasHeight) {
drawImage(ctx, image, {
...drawOpt,
y
})
y += imgHeight
}
return true
}
}
// ...
}
最后就是repeat值,也就是水平和垂直都进行重复:
const handleBackgroundRepeat = ({}) => {
// ...
// 平铺
if (backgroundRepeatValueArr[0] === 'repeat') {
let x = 0
while (x < canvasWidth) {
if (canvasHeight > imgHeight) {
let y = 0
while (y < canvasHeight) {
drawImage(ctx, image, {
...drawOpt,
x,
y
})
y += imgHeight
}
}
x += imgWidth
}
return true
}
}
从左到右,一列一列进行绘制,水平方向绘制到x超出canvas的宽度为止,垂直方向绘制到y超出canvas的高度为止。
最后同样看一下和前两个属性的组合情况。
css样式:
.cssBox {
background-image: url('/4.png');
background-repeat: repeat;
background-size: 50%;
background-position: 50% 50%;
}
效果如下:
图片大小是正确的,但是位置不正确,css的做法应该是先根据background-position的值定位一张图片,然后再向四周进行平铺,而我们显然忽略了这种情况,每次都从0 0位置开始绘制。
知道了原理,解决也很简单,在handleBackgroundPosition方法中已经计算出了x、y,也就是没有平铺前第一张图片的放置位置:
我们只要计算出左边和上边还能平铺多少张图片,把水平和垂直方向上第一张图片的位置计算出来,作为后续循环的x、y的初始值即可。
const handleBackgroundRepeat = ({}) => {
// 保存在handleBackgroundPosition中计算出来的x、y
let ox = drawOpt.x
let oy = drawOpt.y
// 计算ox和oy能平铺的图片数量
let oxRepeatNum = Math.ceil(ox / imgWidth)
let oyRepeatNum = Math.ceil(oy / imgHeight)
// 计算ox和oy第一张图片的位置
let oxRepeatX = ox - oxRepeatNum * imgWidth
let oxRepeatY = oy - oyRepeatNum * imgHeight
// 将oxRepeatX和oxRepeatY作为后续循环的x、y的初始值
// ...
// 平铺
if (backgroundRepeatValueArr[0] === 'repeat') {
let x = oxRepeatX
while (x < canvasWidth) {
if (canvasHeight > imgHeight) {
let y = oxRepeatY
// ...
}
}
}
}
本文简单实现了一下在canvas中模拟css的background-size、background-position、background-repeat三个属性的部分效果,完整源码在https://github.com/wanglin2/simulateCSSBackgroundInCanvas。
*请认真填写需求信息,我们会在24小时内与您取得联系。