Web技巧(11)

发布于 大漠

前段时间在项目使用div元素模拟body时使用了will-change时触发了一个奇特的问题,最终以删除will-change而解决。那么这一期中我们先来聊聊will-changetransform和层有关的事情,然后再和大家一起分享几个在Web中的JavaScript相关的API,比如全屏API、MediaStream API、MediaRecorder API、scrollIntoView API 和 分享 API等。感兴趣的同学请继续往下阅读。

Web中的渲染层和如何控制层

在一些浏览器的调试工具中提供一些调试工具,可以查看当前面的三维视图模式,在该模式中,页面中的HTML嵌套结构,会以图形化的方式,由外到内,从页面底部一级一级凸显出来。这种视图可以让你很容易的看清楚页面的嵌套结构。

而在CSS中有关于渲染层的控制同样要以借助一些CSS的属性来做相应的控制,比如transformz-index等。

浏览器渲染一个Web页面可能会经历下面这样的几个过程:

  • 下载并解析HTML,生成一个DOM树
  • 处理布局文档,生成一个布局树
  • 将布局树转换为渲染指令,生成一个绘制树
  • 生成一个足够容纳整个文档的画布

简单地了解一下有关于样式计算,布局和元素绘制等几个方面。

浏览器下载并解析HTML只会生成一个DOM树,这个时候DOM还不足以获知页面的具体样式,浏览器主进程还会基于CSS选择器解析CSS获取每一个节点的最终的计算样式值。即使不提供任何CSS,浏览器对每个元素也会有一个默认的样式:

如果想要让页面有完整的样式效果,除了获取每个节点的具体样式,还需要获知每一个节点在页面上的位置,布局其实是找到所有元素的几何关系的进程。其具体过程大致如下:

通过遍历DOM及相关的元素的计算样式,主线程会构建出包含每个元素的坐标信息及盒子大小的布局树。布局树和DOM树类似,但是其中只包含页面可见的元素,如果一个元素设置了display: none,这个元素不会出现在布局树上,伪元素虽然在DOM树上不可见,但是在布局树上是可见的。

即使知道了不同元素的位置及样式信息,我们还需要知道不同元素的绘制先后顺序才能正确绘制出整个页面。在绘制阶段,主线程会遍历布局树以创建绘制记录。绘制记录可以看做是记录各元素绘制先后顺序的笔记。

页面的渲染还会经历一个层的合成。正如文章开头所示,页面分割了不同的层,并相互嵌套。而复合是将页面分割为不同的层,并单独栅格化,随后组合为帧的技术。不同层的组合由compositor线程(合成器线程)完成。

主线程会遍历布局树来创建层树(Layer tree),添加了will-change属性的元素,会被看做单独的一层。

有关于浏览器渲染原理方面更多的内容可以阅读:

你可能会想给每一个元素都添加will-change,不过组合过多的层也许会在每一帧都栅格化页面中的某些小部分更慢。那么怎么合理的使用will-change或者说怎么更合理的使用层呢?

@dassurma在他的博客中对这方面做过相应的阐述:

除非你要更改transform,否则不要使用will-change: transform。使用will-change: opacitybackface-visibility:hidden的副作用不会那么令人困扰。

来看看文章中一些有意思的东西,也值得我们去关注和掌握的东西。

在CSS中会经常使用animationtransition来实现一些动效效果。在使用这两个属性时,浏览器会自动将动画元素放到一个层上。它将主画布保留到下一帧,并将额外的工作保持在尽可能低的位置。我们可以在浏览器的调试工具中的Rendering选项卡中启用Layers bordersLayers选项卡可以看到位于单独图层上的元素周围的橙色边框和当前页面上所有层的实时交互视图:

理想情况下呢,都希望浏览器知道什么是合适的,然后去做什么。遗憾的是,事实并非如此。例如,当你使用requestAnimationFrame()在逐帧的基础上处理动画元素。这是很难的,不可能让浏览告诉元素将有一个新值转换每个帧。除非你自己将动画元素放到它自己的层中,否则就会有性能问题,因为浏览器将重新绘制整个文档的每一帧。

在过去,大都会设置transform: translateZ(0)来开启GPU的渲染。因为它将使用GPU计算透视(perspective)失真(即使它最终没有失真)。如果使用translateX()translateY(),则不需要透视图失真,浏览器将使用指定的偏移量将元素绘制到主画布中。

早期这样使用,在Chrome和Safari浏览器会让元素闪烁,所以被建议在样式中设置backface-visibility: hidden,甚至直到今天还这么的使用。

自2016看开始,浏览器对will-change属性的支持度越来越高,而该属性告诉浏览某个CSS属性将会改变。如果你在元素上设置will-change: transform,会告诉浏览器transform属性将在不久的将来发生更改。因此,浏览器可以推测地应用优化来适应这些未来的更改。在转换的情况下,这意味着它将强制元素到它自己的层上。

在某些场景之下,使用will-change:transform会对性能有提高,比如:

可以避免元素重绘。

虽然某些场景对性能有所提高,但也会有相关的副作用。

先来看backface-visibility:hidden带来的副作用 —— 隐藏元素的背面。通常这面不是面向用户的,但是当你在3D空间中旋转你的元素时,它会发生。如下图所示:

对于will-change: transform,前面提到过,该属性会告诉浏览器将来会发生什么。由于这个语义,规范规定设置will-change:<something>必须具有与该<something>属性的任何非初始值相同的副作用。

这似乎很有道理,但当你使用position: fixedposition: absolute时,就会出错:

如果为transform设置一个值,将会创建一个新的包含块。任何具有position: fixedposition: absolute的子元素都会相对于这个新的包含块。这个在一些容器中使用position: fixed的元素将不会再相对于视窗定位,会相对于容器(具有新包含块)定位。

transform: translateZ(0)will-change: transform具有相同的副作用,但是也可 人会干扰使用transform的其他样式,因为这些属性根据级联相互覆盖。

再看一下will-change: opacity,从行为上看上去和前面示例中演示的效果一样,但这并不意味着它没有副作作。设置will-change:opacity会创建一个新的叠加上下文。意味着它可以影响元素渲染的顺序。如果有重叠的元素,它可以更改哪个元素位于顶部。即使出现这种情况,z-index也能帮助你恢复你想要的层叠顺序。

也就是说,使用will-change的时候千万要注意,这也为什么在某些场景下使用will-change会带来副作用。正如上面所述,除了will-change之外,transform: translateZ(0)backface-visibility:hiddenwill-change: opacity多少会带来一些副作用。直到目前为止,使用will-change: opacitybackface-visibility: hidden可以将一个元素强制放到它自己的层上(因为它的副作用似乎最不可能产生问题)。另外,只有当你真正要改变transform时,才应用使用will-change: transform

扩展阅读:

Web Share API

Chrome 61开始为Android系统引入了Web Share API以来似乎并没有引起太多的关注。但在移动端的分享功能却又是不可或缺少的一部分。Web Share API本质上它提供了一种方法,可以Web页面或Web应用程序中提供分享的能力。该API的引入允许开发人员利用用户设备的本地内容共享功能向应用程序或网站添加共享功能。

我们可以先使用navigator.share来做判断,检测用户的浏览器是否支持Web Share API:

if (navigator.share) {
    // Web Share API is supported
} else {
    // Fallback
}

支持Web Share API的浏览器可以调用navigator.share()方法并传递以下这些字段:

  • url:分享的URL字符串
  • title:分享的标题,通常是document.title
  • text:分享的描述内容

来看一个简单的示例:

shareButton.addEventListener('click', event => {
    if (navigator.share) {
        navigator.share({
            title: '来自W3cplus的分享',
            url: 'https://www.fedev.cn'
        }).then(() => {
            console.log('Thanks for sharing!');
        })
        .catch(console.error);
    } else {
        // fallback
    }
});

对于不支持Web Share API的浏览器,我们可以做了个降级方案,比如在Web页面弹出一个Modal框:

shareButton.addEventListener('click', event => {
    if (navigator.share) {
        navigator.share({
            title: 'Web技巧11',
            url: 'https://www.fedev.cn'
        }).then(() => {
            console.log('Thanks for sharing!');
        })
        .catch(console.error);
    } else {
        shareDialog.classList.add('is-open');
    }
});

有关于Web Share API更多的内容可以阅读:

MediaRecorder API

在Web页面或Web应用程序上,我们可以从用户的相机、麦克风等来捕获媒体流。我们可以使用这些媒体流在WebRTC上进行实时的视频聊天。通过MediaRecorder API还可以直接在Web浏览器中记录和保存用户的音频或视频。

要使用MediaRecorder API就先需要一个MediaStream。可以从<video><audio>元素中获取一个,也可以通过调用getUserMedia来捕获用户的相机和麦克风。一旦你有一个流,就可以用它初始化MediaRecorder,也就可以开始录制了。

在记录期间,MediaRecorder对象将发出dataavailable事件,并将记录的数据作为事件的一部分。我们将侦听这些事件并整理数组中的数据块。一旦记录完成,我们将把块数组重新绑定到一个Blob对象中。我们可以通过调用MediaRecorder对象上的startstop来控制录制的开始和结束。

getUserMedia

和Web Share API类似,可以通过下面的方式来检测浏览器是否支持MediaRecorder:

if ('MediaRecorder' in window) {
    // everything is good, let's go ahead
} else {
    renderError ('Sorry, your browser doesn't support the MediaRecorder API, so this demo will not work')
}

对于renderError方法,我们将用错误消息替换某个指定的元素,比如<main>来显示相应的错误信息。可以在事件侦听器之后添加此方法:

function renderError(message) {
    const main = document.querySelector('main');
    main.innerHTML = `<div class="error"><p>${message}</p></div>`;
}

如果浏览器支持MediaRecorder,那我们就可以访问麦克风来记录。为此,我们将使用getUserMedia这个API。另外,我们不会要求直接访问麦克风,因为这对任何用户来说都是一个糟糕的体验。我们可以让用户主动点击用户上面的某个按钮来访问麦克风,然后再向用户发起询问,确定是否开启麦克风来记录。

if ('MediaRecorder' in window) {
    getMic.addEventListener('click', async () => {
        getMic.setAttribute('hidden', 'hidden');
        try {
            const stream = await navigator.mediaDevices.getUserMedia({
            audio: true,
            video: false
        });
        console.log(stream);
        } catch {
            renderError(
            'You denied access to the microphone so this demo will not work.'
        );
    }
  });
} else {
    renderError ('Sorry, your browser doesn't support the MediaRecorder API, so this demo will not work')
}

还可以调用navigator.mediaDevices.getUserMedia返回一个promise,如果用户允许访问该媒体,则该promise将成功解析。因为我们使用的是现代JavaScript,所以可以使用async/wait使这个promise看起来是同步的。我们声明的click事件是一个异步函数,然后当调用getUserMedia时,我们等待结果,然后继续。

当然,用户也有可能会拒绝访问麦克风,我们可以在try/catch语句中封装调用来处理这个问题。如果用户拒绝访问麦克风将导致catch块中代码执行,那么会调用renderError函数。

记录

经过上面的处理,我们可以使用麦克风了,可以准备录音了。但我们还需要将录下来的这些东西存放起来。

首先,我们将使用的是MIME类型,即audio/webm,另外还需要创建一个变量,比如chunks(它是一个数组),用来存储录下来的内容。

MediaRecorder使用从用户麦克风捕获的媒体流和选对象初始化,将传递前面定义的MIME类型:

if ('MediaRecorder' in window) {
    getMic.addEventListener('click', async () => {
        getMic.setAttribute('hidden', 'hidden');
        try {
            const stream = await navigator.mediaDevices.getUserMedia({
            audio: true,
            video: false
        });
        const mimeType = 'audio/webm';
        let chunks = [];
        const recorder = new MediaRecorder(stream, { type: mimeType });
        } catch {
            renderError(
                'You denied access to the microphone so this demo will not work.'
            );
        }
    });
} else {
    renderError ('Sorry, your browser doesn't support the MediaRecorder API, so this demo will not work')
}

现在我们已经创建了MediaRecorder,我们需要为它设置一些事件监听器。记录器出于许多不同的原因发出事件。这些都和记录器本身的交互有关,因此可以在记录器开始记录、暂停、继续和停止时侦听事件。最重要的事件是dataavailable事件,它在记录器积极记录时定期发出。这些事件包含一个记录块,我们将把它推到刚刚创建的chunks数组上。

if ('MediaRecorder' in window) {
    getMic.addEventListener('click', async () => {
        getMic.setAttribute('hidden', 'hidden');
        try {
            const stream = await navigator.mediaDevices.getUserMedia({
            audio: true,
            video: false
        });
        const mimeType = 'audio/webm';
        let chunks = [];
        const recorder = new MediaRecorder(stream, { type: mimeType });
        recorder.addEventListener('dataavailable', event => {
            if (typeof event.data === 'undefined') return;
            if (event.data.size === 0) return;
                chunks.push(event.data);
            });
        recorder.addEventListener('stop', () => {
            const recording = new Blob(chunks, {
                type: mimeType
            });
            renderRecording(recording, list);
            chunks = [];
        });
        } catch {
            renderError(
                'You denied access to the microphone so this demo will not work.'
            );
        }
    });
} else {
    renderError ('Sorry, your browser doesn't support the MediaRecorder API, so this demo will not work')
}

另外,为了方便用户可以把录下来的音保存起来,还可以创建一个函数:

function renderRecording(blob, list) {
    const blobUrl = URL.createObjectURL(blob);
    const li = document.createElement('li');
    const audio = document.createElement('audio');
    const anchor = document.createElement('a');
    anchor.setAttribute('href', blobUrl);
    const now = new Date();
    anchor.setAttribute(
    'download',
    `recording-${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDay().toString().padStart(2, '0')}--${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}.webm`
    );
    anchor.innerText = 'Download';
    audio.setAttribute('src', blobUrl);
    audio.setAttribute('controls', 'controls');
    li.appendChild(audio);
    li.appendChild(anchor);
    list.appendChild(li);
}

最终示例代码如下:

<!-- HTML -->
<div class="controls">
    <button type="button" id="mic">Get Microphone</button>
    <button type="button" id="record" hidden>Record</button>
</div>
<ul id="recordings"></ul>

// JavaScript
window.addEventListener('DOMContentLoaded', () => {
    const getMic = document.getElementById('mic');
    const recordButton = document.getElementById('record');
    const list = document.getElementById('recordings');

    if ('MediaRecorder' in window) {
        getMic.addEventListener('click', async () => {
            getMic.setAttribute('hidden', 'hidden');
            try {
                const stream = await navigator.mediaDevices.getUserMedia({
                    audio: true,
                    video: false
                });

                const mimeType = 'audio/webm';
                let chunks = [];
                const recorder = new MediaRecorder(stream, { type: mimeType });

                recorder.addEventListener('dataavailable', event => {
                    if (typeof event.data === 'undefined') return;
                    if (event.data.size === 0) return;
                    chunks.push(event.data);
                });

                recorder.addEventListener('stop', () => {
                    const recording = new Blob(chunks, {
                        type: mimeType
                    });
                    renderRecording(recording, list);
                    chunks = [];
                });

                recordButton.removeAttribute('hidden');

                recordButton.addEventListener('click', () => {
                    if (recorder.state === 'inactive') {
                        recorder.start();
                        recordButton.innerText = 'Stop';
                    } else {
                        recorder.stop();
                        recordButton.innerText = 'Record';
                    }
                });
            } catch {
                renderError(
                    'You denied access to the microphone so this demo will not work.'
                );
            }
        });
    } else {
        renderError(
            "Sorry, your browser doesn't support the MediaRecorder API, so this demo will not work."
        );
    }
});

function renderError(message) {
    const main = document.querySelector('main');
    main.innerHTML = `<div class="error"><p>${message}</p></div>`;
}

function renderRecording(blob, list) {
    const blobUrl = URL.createObjectURL(blob);
    const li = document.createElement('li');
    const audio = document.createElement('audio');
    const anchor = document.createElement('a');
    anchor.setAttribute('href', blobUrl);
    const now = new Date();
    anchor.setAttribute(
      'download',
      `recording-${now.getFullYear()}-${(now.getMonth() + 1)
        .toString()
        .padStart(2, '0')}-${now
        .getDay()
        .toString()
        .padStart(2, '0')}--${now
        .getHours()
        .toString()
        .padStart(2, '0')}-${now
        .getMinutes()
        .toString()
        .padStart(2, '0')}-${now
        .getSeconds()
        .toString()
        .padStart(2, '0')}.webm`
    );
    anchor.innerText = 'Download';
    audio.setAttribute('src', blobUrl);
    audio.setAttribute('controls', 'controls');
    li.appendChild(audio);
    li.appendChild(anchor);
    list.appendChild(li);
}

示例代码来自@Phil nash《An introduction to the MediaRecorder API》一文。

扩展阅读:

MediaStream API

MediaStream API 也可以用来帮助你创建来自用户输入设备的数据流,比如视频(摄像机)或音频(麦克风)。该API有两个核心方法:.getUserMedia()navigator.mediaDevices

请注意,此对象仅在HTTPS-secured上下文中才可用。

async function getMedia() {
    const constraints = {
        // ...
    };
    let stream;

    try {
        stream = await navigator.mediaDevices.getUserMedia(constraints);
        // use the stream
    } catch (err) {
        // handle the error - user's rejection or no media available
    }
}

getMedia();

该方法接受所谓的constraints对象,并返回一个promise,该promise解析为一个新的MediaStream实例。这样的接口是当前流媒体的表示。它由零个或多个独立的MediaStreamTrack组成,每个MediaStreamTrack表示音频或视频流,而音频轨道由左右通道组成(用于立体声和其他东西)。如果需要进一步控制,这些轨道还提供了一些特殊的方法和事件。

constraints对象是较为重要的部分。它用于配置.getUserMedia()的请求和生成的流。此对象可以具有布尔值或对象值的两个属性:audiovideo

const constraints = {
    video: true,
    audio: true
};

通过将它们都设置为true,我们请求访问用户的默认视频和音频的输入设备,并应用默认设置。要知道,为了让.getUserMedia()能正常工作,必须至少设置这两个属性中的一个。

如果希望进一步配置媒体设备的设置,需要传递一个对象。这里提供的属性列表非常长,并且根据应用于视频或音频的曲目类型不同而有所不同。你可以在这里看到完整的列表,并使用.getSupportedConstraints()方法检查可用的列表。

假设这次我们想更具体一些,为视频轨道指定一些额外的配置。

async function getConstraints() {
    const supportedConstraints = navigator.mediaDevices.getSupportedConstraints();
    const video = {};

    if (supportedConstraints.width) {
        video.width = 1920;
    }
    if (supportedConstraints.height) {
        video.height = 1080;
    }
    if (supportedConstraints.deviceId) {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const device = devices.find(device => {
            return device.kind == "videoinput";
        });
        video.deviceId = device.deviceId;
    }

    return { video };
}

另外我们可以使用.enumerateDevices()方法检查可用的输入设备,同时设置视频的分辨率。它返回一个promise,该promise解析为一个MediaDeviceInfo对象数组。来看一个简单的小示例:

<!-- HTML -->
<video autoplay></video>
<audio autoplay></audio>

// JavaScript
async function getConstraints() {
    const supportedConstraints = navigator.mediaDevices.getSupportedConstraints();
    const video = {};

    if (supportedConstraints.width) {
        video.width = 1920;
    }
    if (supportedConstraints.height) {
        video.height = 1080;
    }
    if (supportedConstraints.deviceId) {
        const devices = await navigator.mediaDevices.enumerateDevices();
        const device = devices.find(device => {
            return device.kind == "videoinput";
        });
        video.deviceId = device.deviceId;
    }

    return { video, audio: true };
}

async function getMedia() {
    const constraints = await getConstraints();
    const video = document.querySelector("video");
    const audio = document.querySelector("audio");
    let stream = null;

    try {
        stream = await navigator.mediaDevices.getUserMedia(constraints);
        console.log(stream, video.srcObject)
        video.srcObject = stream;
        audio.srcObject = stream;
        // use the stream
    } catch (err) {
        // handle the error - user's rejection or no media available
    }
}
getMedia();

扩展阅读:

Fullscreen API

有的时候需要将页面全屏显示。CSS中有一些伪元素,可以实现该效果:

:-webkit-full-screen body,
:-moz-full-screen body,
:-ms-fullscreen body {
    /* properties */
    width: 100vw;
    height: 100vh;
}

:full-screen body {
    /*pre-spec */
    /* properties */
    width: 100vw;
    height: 100vh;
}

:fullscreen body {
    /* spec */
    /* properties */
    width: 100vw;
    height: 100vh;
}

/* deeper elements */

:-webkit-full-screen body {
    width: 100vw;
    height: 100vh;
}

/* styling the backdrop*/

::backdrop,
::-ms-backdrop {
    /* Custom styles */
}

而苹果去年秋天在iPad Safari上推出了对全屏API的支持。这意味着开发人员现在可以在iPad上为用户创建完全沉浸式的Web应用程序。它可靠地消除了屏幕上的所有干扰,帮助用户专注于手头的任务。就像一个本地应用程序一样,可以全屏显示。

下面的代码就是使用原生JavaScript来实现全屏效果的。首先创建了一个名为_toggleFullScreen的函数,让你在全屏和url模式之间切换:

const _toggleFullScreen = function _toggleFullScreen() {
    if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement) {
        if (document.cancelFullScreen) {
            document.cancelFullScreen();
        } else {
            if (document.mozCancelFullScreen) {
                document.mozCancelFullScreen();
            } else {
                if (document.webkitCancelFullScreen) {
                    document.webkitCancelFullScreen();
                }
            }
        }
    } else {
        const _element = document.documentElement;
        if (_element.requestFullscreen) {
            _element.requestFullscreen();
        } else {
            if (_element.mozRequestFullScreen) {
                _element.mozRequestFullScreen();
            } else {
                if (_element.webkitRequestFullscreen) {
                    _element.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
                }
            }
        }
    }
};

这个_toggleFullScreen函数处理所有浏览器的前缀和特性。现在我们只需要确认用户使用的设备、浏览器和iOS版本,这样就可以启用全屏功能了。

  • 使用使用的是iPad
  • 使用的是Safari浏览器
  • iOS版本是12以及更高的版本

不过我们可以采用window.navigator.userAgent来做检测:

const userAgent = window.navigator.userAgent;

const iPadSafari =
    !!userAgent.match(/iPad/i) &&  		// Detect iPad first.
    !!userAgent.match(/WebKit/i) && 	// Filter browsers with webkit engine only
    !userAgent.match(/CriOS/i) &&		// Eliminate Chrome & Brave
    !userAgent.match(/OPiOS/i) &&		// Rule out Opera
    !userAgent.match(/FxiOS/i) &&		// Rule out Firefox
    !userAgent.match(/FocusiOS/i);		// Eliminate Firefox Focus as well!

    const element = document.getElementById('fullScreenButton');

    function iOS() {
        if (userAgent.match(/ipad|iphone|ipod/i)) {
            const iOS = {};
            iOS.majorReleaseNumber = +userAgent.match(/OS (\d)?\d_\d(_\d)?/i)[0].split('_')[0].replace('OS ', '');
            return iOS;
        }
    }

    if (element !== null) {
        if (userAgent.match(/iPhone/i) || userAgent.match(/iPod/i)) {
            element.className += ' hidden';
        } else if (userAgent.match(/iPad/i) && iOS().majorReleaseNumber < 12) {
            element.className += ' hidden';
        } else if (userAgent.match(/iPad/i) && !iPadSafari) {
            element.className += ' hidden';
        } else {
            element.addEventListener('click', _toggleFullScreen, false);
        }
    }

示例代码来自于@Marvin Danig的《How to go fullscreen on iPad Safari》一文。

来看个示例:

扩展阅读:

scrollIntoView API

在《滚动的特性》和《改变用户体验的滚动新特性》中我们都提到了CSS的overscroll-behavior可以控制一个容器或页面body容器滚动时发生的默认行为。可以使用这个属性取消滚动链接、禁用、自定义下拉刷新,禁用在iOS上的回弹效果等。而且使用overscroll-behavior不会对页面有性能影响。

在JavaScript中提供了一个scrollIntoView API,可以指示浏览器将一个元素添加到视窗端口。通过在scrollIntoViewOption对象上添加行为属性,可以指示scrollIntoView API使用滚动部分具有动画效果。

element.scrollIntoView({ behavior: 'smooth' });

比如使用JavaScript来自动检测对锚点的点击,这样浏览器就会跳到锚点目标。这种跳转可能会让用户迷失方向(因为它会一闪就跳过去了),所以让这个动画有一个过程化将会大大改善用户体验。

// Everytime someone clicks on something
document.body.addEventListener('click', e => {

    const href = e.target.href;
    
    // no href attribute, no need to continue then
    if (!href) return;
    
    const id = href.split('#').pop();
    const target = document.getElementById(id);
    
    // no target to scroll to, bail out
    if (!target) return;
    
    // prevent the default quick jump to the target
    e.preventDefault();
    
    // set hash to window location so history is kept correctly
    history.pushState({}, document.title, href);
    
    // smooooooth scroll to the target!
    target.scrollIntoView({
        behavior: 'smooth',
        block: 'start'
    });

});

扩展阅读:

Web Animations API

Web Animations API已经出来很久了,而且非常的棒。除了能支持平常动画之外,还可以使用PromiserAF和CSS的transition来重新创建它们,会让动画效果更接近人体工程学。

使用element.animation可以非常轻易的让任何元素根据动画序列帧播放,类似CSS的@keyframes动画:

element.animate([
    {transform: 'translateX(0px)', backgroundColor: 'red'},
    {transform: 'translateX(100px)', backgroundColor: 'blue'},
    {transform: 'translateX(50px)', backgroundColor: 'green'},
    {transform: 'translateX(0px)', backgroundColor: 'red'},
    //...
], {
    duration: 3000,
    iterations: 3,
    delay: 0
}).finish.then(_ => console.log('I’m done animating!'));

然而,日常开发要像上面那样使用动画系列声明的方式来定义动画,需要使用Web Animation Polyfill,它包含了我们实际所需要的更多功能:

Object.assign(element.style,
    {
        transition: 'transform 1s, background-color 1s',
        backgroundColor: 'red',
        transform: 'translateX(0px)',
    }
);

requestAnimationFramePromise()
    .then(_ => animate(element,
        {transform: 'translateX(100px)', backgroundColor: 'blue'}))
    .then(_ => animate(element,
        {transform: 'translateX(50px)', backgroundColor: 'green'}))
    .then(_ => animate(element,
        {transform: 'translateX(0px)', backgroundColor: 'red'}))
    .then(_ => console.log('I’m done animating!'));

当你使用CSS Transition让一个元素具有一个动画效果,其实他有一个transitioned事件:

function transitionEndPromise(element) { 
    return new Promise(resolve => { 
        element.addEventListener('transitionend', function f() { 
            element.removeEventListener('transitionend', f); 
            resolve(); 
        }); 
    }); 
}

这样使用后不会注册我们的侦听器,也不会泄露内存!有了这个,我可以使用Promises替代回调,等待动画的结束。

我们也可以对requestAnimationFrame进行包装,让其变得更简单:

function requestAnimationFramePromise() {
    return new Promise(resolve => requestAnimationFrame(resolve));
}

有了这个,我们可以使用Promise而不是回调来等待下一帧。

我们可以将它合并到我们自己的element.animate()中:

function animate(element, stylz) {
    Object.assign(element.style, stylz);
    return transitionEndPromise(element)
        .then(_ => requestAnimationFramePromise());
}

这是对animationtransition非常轻量的抽象处理,会给很多开发人员带来很多的便利。

如果您在两个元素上使用这个技术,而其中一个元素是另一个元素的祖先,那么从继承者的传递结束事件将使前面的动画链向前推进。幸运的是,通过检查事件,可以很容易的解决这样的现象,比如event.target

function transitionEndPromise(element) {
    return new Promise(resolve => {
        element.addEventListener('transitionend', function f(event) {
            if (event.target !== element) return;
            element.removeEventListener('transitionend', f);
            resolve();
        });
    });
}

上面的的示例代码来自于@Surma的《DIY Web Animations: Promises + rAF + Transitions》一文

扩展阅读:

小结

这一期中我们先来聊聊will-change、transform和层有关的事情,然后再和大家一起分享几个在Web中的JavaScript相关的API,比如全屏API、MediaStream API、MediaRecorder API、scrollIntoView API 和 分享 API等。