前言
关于这道经典的面试题,笔者准备写三篇文章来对知识点进行深度和广度的挖掘:
- 深入探究“在浏览器输入URL到渲染页面”(上)过程剖析
- 深入探究“在浏览器输入URL到渲染页面”(中)性能优化
- 深入探究“在浏览器输入URL到渲染页面”(下)模拟面试
正文
在 上一篇文章 中写到,用户在输入 url 并回车之后,浏览器经历了下面这些阶段:
重定向 → 拉取缓存 → DNS 查询 → 建立 TCP 连接 → 发起请求 → 接收响应 → 处理 html 元素 → 页面进行渲染。
因此性能优化也可以从这几个阶段入手:
- 网络请求过程
减少请求数量:
- 永久重定向
- 浏览器缓存
- DNS 预解析
- 合并文件
- 使用雪碧图
- 避免使用空的 src 和 href
- 不使用CSS @import
减小请求资源大小:
- 资源打包压缩
- js 压缩
- html 压缩
- css 压缩
- tree-shaking 剔除没用到的代码
- 服务端开启 gzip 压缩
- 图片资源优化
- 不要在 html 里缩放图像
- 使用雪碧图(css sprite)
- 使用字体图标(iconfont)
- 使用 webp
提升网络传输速率:
- 使用 HTTP2
- 静态资源走 CDN 引入
- 浏览器渲染过程
资源加载位置:
- css 文件放在
<head>
- js 文件放在
<body>
底部
资源加载时机:
- 使用预加载机制
- 异步 script 标签
- 模块按需加载
- 图片懒加载
资源渲染过程:
- 减少重排与重绘
- 使用服务端渲染
- 其他
- 使用
requestAnimationFrame
替代setTimeout
和setInterval
- 防抖和节流
一、网络请求过程
我们常将网络性能优化措施归结为三大方面:减少请求数量、减小请求资源大小、提升网络传输速率。
减少请求数量:
减少重定向
原理:当页面发生了重定向,就会重新发送 url 请求,延迟整个 HTML 文档的传输。
如果一定要使用重定向,就使用 301
永久重定向。
实例:
HTTP/1.1 301 Moved Permanently
Location: http://example.com/newuri
Content-Type: text/html
浏览器缓存
原理:使用 Cache-Control
或 Expires
这类强缓存时,在缓存不过期的情况下,可以直接使用缓存的文件,而不向服务器请求资源。若强缓存失效,还可以使用 Last-Modified
或 ETag
这类协商缓存,向服务器发送请求,在资源不发生变化的情况下,可以直接从本地缓存加载资源;若资源发生变化,则服务器会将更新后的资源发送给浏览器。
DNS 预解析
使用:在 html
文件的 <head>
头部中加上 <link>
标签。<link>
标签中①设置属性 rel
,赋值为 dns-prefetch
;②设置属性 href
,赋值为 要解析的域名 (写域名和端口号就可以了)。
实例:
<link rel="dns-prefetch" href="www.baidu.com" />
合并文件
原理:将多个文件合并,这样在请求时就只发送一个 HTTP 请求来请求资源。
但这样也有它的坏处:①首屏渲染变慢;②缓存失效问题。
因此对于文件合并,有两点改进:①公共库合并;②不同页面单独合并。
使用雪碧图
原理:将网站上用到的一些图片整合到一张单独的图片中,从而减少请求图片的次数。
使用:
- 合成雪碧图:
配置 webpack :webpack-spritesmith
插件提供了自动合成雪碧图的功能并且可以自动生成对应的样式文件。
var path = require('path');
var SpritesmithPlugin = require('webpack-spritesmith');
module.exports = {
// ...
plugins: [
new SpritesmithPlugin({
src: {
cwd: path.resolve(__dirname, 'img'), // 图片的存放目录
glob: '*.png', // 要合并的图片
},
target: {
image: path.resolve(__dirname, 'spriteImg/sprite.png'),
css: path.resolve(__dirname, 'spriteImg/sprite.styl'),
},
apiOptions: {
cssImageRef: '~sprite.png',
},
}),
],
}
通过上面配置就能将 img
目录下的所有 png
文件合成雪碧图,并且输出到对应目录,同时还可以生成对应的样式文件,样式文件的语法会根据你配置的样式文件的后缀动态生成。
如果不使用 webpack 插件的话,也可以使用在线生成雪碧图的网站:www.toptal.com/developers/
- 使用雪碧图:
通过background-position
属性来设置资源在雪碧图中的哪一个位置。
避免使用空的 src 和 href
原理:a 标签设置空的 href ,会重定向到当前的页面地址;form 设置空的 method ,会提交表单到当前的页面地址。
不使用CSS @import
原理:使用 css @import 会导致 css 无法并行下载,因为使用 @import 引用的文件只有在引用它的那个文件被下载、解析之后,浏览器才会知道还有另外一个 css 需要下载,这时才去下载这个 css 文件,然后在下载后开始解析、构建 layout tree 等一系列操作。因此 css @import 引起的 css 解析延迟会加长页面留白期。
减小请求资源的大小:
资源打包压缩
- 压缩 JS 代码
在 webpack 的 production 模式中,会自动压缩 js 代码。
optimization: {
minimizer: [
new UglifyJsPlugin({
cache: true,
parallel: true,
sourceMap: true // set to true if you want JS source maps
}),
...Plugins
];
}
压缩 HTML 代码
使用 html-webpack-plugin 中的 minify 进行压缩。new HtmlWebpackPlugin({ minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true }, chunksSortMode: "dependency" });
压缩 CSS 代码
使用 cssnano 压缩 css。在 postcss.config.js 中进行配置。const cssnano = require("cssnano"); module.exports = { plugins: [cssnano] };
使用 tree-shaking 剔除未使用过的代码
作用:**tree shaking
可以去除未引用代码,减少代码体积**。
使用:在 package.json
中,添加字段:sideEffects: false,告诉 Webpack 所有代码都没有副作用(都可以进行tree shaking),然后再根据不同的环境进行不同的配置。
package.json
:
{
"name": "webpack-demo-1",
"sideEffects": false,
// ...
}
开发环境下的配置:
// webpack.config.js
module.exports = {
// ...
mode: 'development',
optimization: {
usedExports: true,
}
};
生产环境下的配置:
在生产环境下,Webpack
默认会添加 Tree Shaking
的配置,因此只需写一行 mode: ‘production’ 即可。
// webpack.config.js
module.exports = {
// ...
mode: 'production',
};
注意:
如果想要对一段代码做
Tree Shaking
处理,那么就要避免引入整个库到一个 JS 对象上,如果你这么做了,Webpack 就会认为你是需要这整个库的,这样就不会做 Tree Shaking 处理。Tree Shaking 只支持 ESM 的引入方式,不支持 Common JS 的引入方式。
ESM: export + import
Common JS: module.exports + require
- 使用 gzip 压缩
流程:①浏览器发起请求时,在请求头中设置属性 accept-encoding: gzip
表明浏览器支持 gzip 。②服务器根据请求头信息判断浏览器是否支持 gzip ,支持的话就向浏览器传送压缩过的内容,并在响应头上带上 content-encoding: gzip
;不支持的话则直接向浏览器发送未经过压缩的内容。③浏览器接收到服务器响应后根据响应头判断内容是否被压缩,如果被压缩则解压缩后再显示内容。
详细原理可参考 gzip压缩算法。
图片资源优化
不要在 HTML 里缩放图像
不要使用<img>
的width、height
缩放图片,如果用到小图片,就使用相应大小的图片。如果需要<img width="100" height="100" src="mycat.jpg" alt="My Cat" />
,那么图片本身(mycat.jpg)应该是100x100px
的,而不是去缩小500x500px
的图片。使用字体图标(iconfont)代替图片图标
字体图标就是将图标制作成一个字体,使用时就跟字体一样,可以设置属性如font-size
、color
等。并且字体图标是矢量图,不会失真,而且生成的文件特别小。Base64
将图片的内容以Base64
格式内嵌到 HTML 中,可以减少 HTTP 请求数量。但是,由于Base64
编码用8
位字符表示信息中的6
个位,所以编码后大小大约比原始值扩大了33%
。使用 Webp
webp
的优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼无差异的图像质量;同时具备了无损和有损的压缩模式、alpha
透明以及动画的特性,在jpeg
和png
上的转化效果都想当优秀、稳定和统一。
提升网络传输速率:
使用 HTTP2
原理:在一次 HTTP 请求中,底层会通过 tcp 建立连接。而 tcp 协议存在 3 次握手,4 次挥手阶段,这些机制虽然保证了 tcp 的可靠性,但降低了传输效率,为了解决这个问题,我们可以使用 http2 来增加传输时候效率。
http2 相比 http1 主要有以下几点优化:
解析速度快
服务器解析 HTTP1.1 的请求时,必须不断地读入字节,直到遇到分隔符 CRLF 为止。而解析 HTTP2 的请求就不用这么麻烦,因为 HTTP2 是基于帧的协议,每个帧都有表示帧长度的字段。多路复用
HTTP1.1 如果要同时发起多个请求,就得建立多个 TCP 连接,因为一个 TCP 连接同时只能处理一个 HTTP1.1 的请求。在 HTTP2 上,多个请求可以共用一个 TCP 连接,这称为多路复用。同一个请求和响应用一个流来表示,并有唯一的流 ID 来标识。 多个请求和响应在 TCP 连接中可以乱序发送,到达目的地后再通过流 ID 重新组建。首部压缩
多次请求中,可能有很多数据都是重复的,HTTP/2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,传输时只需要传递索引值即可,对于相同的数据,不再通过每次请求和响应发送。服务器端推送
服务器根据页面内容,主动把页面需要的资源传给客户端,减少请求数。
静态资源走 CDN 引入
原理:当访问一个网站时,如果没有部署 CDN ,浏览器获取资源就是通过 DNS 解析得到 ip 后,向该 ip 地址请求资源;
如果该网站部署了 CDN ,那么就
- 浏览器向本地 DNS 发出请求以获取 ip
- 本地 DNS 服务器依次向根域名服务器、顶级域名服务器、权限域名服务器发出请求,得到全局负载均衡系统(GSLB)的 ip 地址
- 本地 DNS 服务器再向 GSLB发出请求。
- GSLB 会根据请求中携带的 ip 地址判断用户的位置,筛选出距离用户较近的本地负载均衡系统(SLB),并将该 SLB 的 ip 地址作为结果返回给本地 DNS 服务器。
- 本地 DNS 服务器将 SLB 的 ip 地址返回给浏览器,浏览器向 SLB 发出请求。
- SLB 根据浏览器请求的资源和地址,选出最优的缓存服务器返回给浏览器。
- 浏览器再根据 SLB 发回的地址重定向到缓存服务器。
- 如果缓存服务器有浏览器需要的资源,就将资源返回给浏览器。如果没有,就老老实实向源服务器请求资源,再将资源发给浏览器以缓存在本地。
查看:执行以下命令查看用户与服务器之间经过的所有路由器:
# linux
traceroute baidu.com
# windows
tracert baidu.com
二、页面渲染性能优化
页面要进行渲染,首先要先加载资源,那么优化便可分为资源加载位置、资源加载时机和资源渲染过程。
资源加载位置:
css 文件放在 <head>
中
优先使用外部文件样式,其次才是本文件的样式;
js 文件放在 <body>
底部
也是优先使用外部脚本文件,其次才是本文件的脚本代码。
资源加载时机:
使用预加载机制
原理:在 html 加载时,会加载很多第三方资源,这些资源的优先级是不同的,一些重要资源需要提前进行获取,而一些资源可以延迟进行加载。我们可以使用 DNS 预解析,预加载,预获取,预连接,预渲染来管理页面资源的加载。
使用:
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//www.baidu.com" />
<!-- 预加载,提前加载某些内容,可以通过 as 指定要加载的资源类型-->
<link rel="preload" href="http://example.com" />
<!-- 预获取,提前拉取指定的资源到缓存中 -->
<link rel="prefetch" href="http://example.com" />
<!-- 预连接,提前和指定服务器建立通信连接 -->
<link rel="preconnect" href="http://example.com" />
<!-- 预渲染,提前渲染下一页的数据 -->
<link rel="prerender" href="http://example.com" />
异步 script 标签
<script>
标签有两个属性:defer
和 async
,可以控制文件的执行顺序。
- defer:异步加载文件,和 HTML 解析同步进行。文件加载完成之后,在 HTML 解析完成之后执行,效果类似于将代码放在 body 底部。
- async:异步加载文件,和 HTML 解析同步进行。文件加载完成之后立即执行,不管 HTML 是否解析完成。
模块按需加载
待补充……
图片懒加载
概念:懒加载也叫延迟加载、按需加载,指的是在页面渲染时不一次性渲染所有图片,而是只加载在页面可视区内的图片。
优点:
- 减少无用资源的加载:使用一次性将所有图片加载出来,但用户可能只浏览一部分图片而已,那些没被浏览到的图片其实可以先不用渲染出来。
- 提升用户体验:一次性加载太多图片,可能需要等待的时间较长,这样就影响了用户体验,使用懒加载后,渲染压力就会变小很多。
- 防止加载过多图片而影响其他资源文件的加载。
原理:
给 <img>
标签定义一个 data-src
属性,赋值为图片地址,并将 src
属性赋值为 ''
,这样图片就渲染不出来了,当图片出现在页面可视区的时候,再将 data-src
的值赋给 src
即可完成懒加载。
注意:data-src
是自定义的,你也可以定义为 data-xxx
。
这里的难点在于怎么确定可视区域,有两种方法:
- 方法一:使用原生 js
知识点:
(1)window.innerHeight
是浏览器可视区的高度(不包含浏览器地址栏、书签那块)
(2)document.body.scrollTop || document.documentElement.scrollTop
是浏览器滚动过的距离
(3)imgs.offsetTop
是图片元素顶部距离可视区顶部的高度(包括滚动条的距离)
(4)图片加载条件: 图片顶部到文档顶部的距离 < 浏览器可视高度 + 浏览器滚动过的高度,也就是img.offsetTop < window.innerHeight + document.body.scrollTop
代码实现:
<div class="container">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
<img src="loading.gif" data-src="pic.png">
</div>
const imgs = document.querySelectorAll('img');
function lozyLoad () {
const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
const winHeight = window.innerHeight;
for (let i = 0; i < img.length; i++) {
if (imgs[i].offsetTop < scrollTop + winHeight) {
imgs[i].src = imgs[i].getAttribute('data-src');
}
}
}
window.onscroll = lozyLoad();
- 方法二:使用
IntersectionObserver
用法:IntersectionObserver
是浏览器原生提供的构造函数,接受两个参数:callback
是可见性变化时的回调函数,option
是配置对象(该参数可选)。
var io = new IntersectionObserver(callback, option);
构造函数的返回值 io
是一个观察器实例,实例的observe
方法可以指定观察哪个 DOM 节点。
// 开始观察,参数是一个 DOM 节点对象
io.observe(document.getElementById('example'));
// 停止观察
io.unobserve(element);
// 关闭观察器
io.disconnect();
如果要观察多个节点,就要多次调用这个方法。
io.observe(elementA);
io.observe(elementB);
资源渲染过程:
减少重绘重排
1. 缓存 DOM
原理:查询 DOM 比较耗时,因此在同一个节点需要多次查询的情况下,可以缓存 DOM 。
使用:const div = document.getElementById('div')
2. 减少 DOM 深度及 DOM 数量
原理:HTML 中标签元素越多,标签的层级越深,浏览器解析 DOM 并绘制到浏览器中所花的时间就越长。
3. DOM 读写分离
不要两个读操作之间,加入一个写操作。
原理:当 DOM 变动和样式变动时,会触发页面的重新渲染。但浏览器会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,尽量避免多次重新渲染。这时如果在修改样式中间进行了其他不是修改样式的操作,就会触发多次重排。
4. 批量操作 DOM
原理:在元素脱离文档流之后,对该元素进行的多次操作,不会触发回流。等操作结束后,再将元素放回标准流即可达到效果。
脱离文档流的方法:
- ①隐藏元素;
- ②使用文档碎片;
- 原理:使用
DocumentFragment
对象,在内存中操作 DOM 不会引发页面重排。
- 原理:使用
- ③拷贝节点;
- ④
position
属性设置为absolute
或fixed
。
实例:对 DOM 节点进行多次操作:给每个 ul
新增一个 li
,要使其不会触发回流
// 原来:
var oUl = document.querySelector("ul");
for(var i = 0; i < data.length; i++) {
var oLi = document.createElement("li");
oLi.innerText = data[i].name;
oUl.appendChild(oLi);
}
// 修改方案1:隐藏元素
var oUl = document.querySelector("ul");
oUl.style.display = 'none';
for(var i = 0; i < data.length; i++) {
var oLi = document.createElement("li");
oLi.innerText = data[i].name;
oUl.appendChild(oLi);
}
oUl.style.display = 'block';
// 修改方案2:使用文档碎片
var fragment = document.createDocumentFragment();
for(var i = 0; i < data.length; i++) {
var oLi = document.createElement("li");
oLi.innerText = data[i].name;
fragment.appendChild(oLi);
}
oUl.appendChild(fragment);
// 修改方案3:拷贝节点
var newUl = oUl.cloneNode(true);
for(var i = 0; i < data.length; i++) {
var oLi = document.createElement("li");
oLi.innerText = data[i].name;
newUl.appendChild(oLi);
}
oUl.parentElement.replaceChild(newUl, oUl);
// 修改方案4:position 属性为 absolute 或 fixed
5. 合并修改样式
原理:如果要给一个节点操作多个 css 属性,而每修改一种样式都会造成回流,因此尽可能将多次操作合并成一个。
实例:
// 原来:
var oDiv = document.querySelector('.box');
oDiv.style.padding = '5px';
oDiv.style.border = '1px solid #000';
oDiv.style.margin = '5px';
// 修改方案1:使用 style 的 cssText
var oDiv = document.querySelector('.box');
oDiv.style.cssText = 'padding:5px; border:1px solid #000; margin:5px';
// 修改方案2:将这几个样式定义给一个类名,然后给标签添加该类名
.pbm{
padding:5px;
border:1px solid #000;
margin:5px;
}
var oDiv = document.querySelector('.box');
oDiv.classList.add('pbm');
6. 避免使用 table 布局
一个小的改动可能会使整个table
进行重新布局
7. 给图片指定大小
原理:因为 img
元素是内联元素,所以在加载图片后悔改变宽高,因此最好在渲染前就指定图片的大小,或者让其脱离文档流。
8. 合理使用硬件加速(GPU 加速)
我们知道,浏览器页面的绘制是由 GPU 完成的,因此减少重排重绘也可以从 GPU 缓存 这方面入手。例如:把那些会发生大量重排重绘的元素提取出来,单独触发一个渲染层,那么这个元素就不会影响其它层一块重绘了。
创建渲染层的方式:
满足以下任意情况便会创建层:
will-change
设置为opacity、transform、top、left、bottom、right
(推荐)<video>
元素<canvas>
元素- css 3D
-webkit-transform: translateZ(0); -webkit-transform: translate3d(250px,250px,250px) rotate3d(250px,250px,250px,-120deg) scale3d(0.5, 0.5, 0.5);
- css 滤镜
- 混合插件(如 Flash)
- 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
- 元素有一个z-index较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)
最常用的方法,就是给某个元素添加下面的样式:
-webkit-transform: translateZ(0);
-moz-transform: translateZ(0);
-ms-transform: translateZ(0);
-o-transform: translateZ(0);
transform: translateZ(0);
/* 在 Chrome and Safari中可能会有页面闪烁的效果,下面的代码可以修复此情况:*/
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-perspective: 1000;
-moz-perspective: 1000;
-ms-perspective: 1000;
perspective: 1000;
在webkit内核的浏览器中,另一个行之有效的方法是:
-webkit-transform: translate3d(0, 0, 0);
-moz-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
服务端渲染
待补充……
三、其他
防抖和节流
原理:在短时间内对一个元素进行多次的重复操作,可以设置防抖和节流,防止该操作的频繁触发。
实现:
防抖:
基础版:每次触发事件后都会等过了指定的延迟时间才执行func
函数。function debounce(func, wait) { let timeour = null; return function() { const _this = this; const args = arguments; if (timeout) { clearTimeout(timeout); timeout = null; } timeout = setTimeout(() => { func.apply(_this, args); }, wait); } }
进阶版:事件触发的时候马上执行
func
函数。function debounce(func, wait, immediate) { let timeout = null; return function() { let _this = this; let args = arguments; if (timeout) { clearTimeout(timeout); timeout = null; } // 触发后立即执行 if (immediate) { // 如果两次触发的间隔小于 wait,此时 timeout 还不为 null,不执行 func if (!timeout) { func.apply(_this, args); } // wait 时间后把 timeouut 重新设置为 null,表示可以再次执行 func 了 timeout = setTimeout(function() { timeout = null; }, wait); } else { timeout = setTimeout(function() { func.apply(_this, args); }, wait); } } }
节流:
定时器版function throttle(func, wait) { let timeout = null; return function () { let _this = this; let args = arguments; if (!timeout) { timeout = setTimeout(() => { func.apply(_this, args); timeout = null; }, wait); } } }
时间戳版
function throttle(func, wait) { let previous = 0; return functoin() { let now = +new Date(); const _this = this; const args = arguments; if (now - previous >= wait) { func.apply(_this, args); previous = now; } } }
两者合并版
function throttle(func, wait) { let previous = 0; let timeout = null; return functoin() { let now = +new Date(); const _this = this; const args = arguments; let remaining = wait - (now - previous); // 为了第一次触发能够马上执行(时间戳思想) if (remaining <= 0) { // 需要先清空定时器,否则会重复执行 if(timeout) { clearTimeout(timeout); timeout = null; } func.apply(_this, args); previous = now; } // 为了最后一次触发还能够再执行一次(定时器思想) else { if(!timeout) { timeout = setTimeout(function() { func.apply(_this, args); timeout = null; previous = +new Date(); }, remaining) } } } }
使用 requestAnimationFrame
替代 setTimeout
和 setInterval
希望在每一帧刚开始的时候对页面进行更改,目前只有使用 requestAnimationFrame 能够保证这一点。使用 setTimeout 或者 setInterval 来触发更新页面的函数,该函数可能在一帧的中间或者结束的时间点上调用,进而导致该帧后面需要进行的事情没有完成,引发丢帧。
当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()
语法:window.requestAnimationFrame(callback);
参数:callback
表示下一次重绘之前更新动画帧所调用的函数(即上面所说的回调函数)。
返回值:一个 long
整数,请求 ID ,是回调列表中唯一的标识。用于传值给 window.cancelAnimationFrame()
以取消回调函数。
范例:
const element = document.getElementById('some-element-you-want-to-animate');
let start;
function step(timestamp) {
if (start === undefined)
start = timestamp;
const elapsed = timestamp - start;
//这里使用`Math.min()`确保元素刚好停在200px的位置。
element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';
if (elapsed < 2000) { // 在两秒后停止动画
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);