続きを読む: Chrome v84 固定ビューポートの新機能、リストの「load more」機能に問題あり
コンテキスト
このウェブサイトには、次のような「クリックして続きを読む」機能があります。
ボタンをクリックすると、リストにデータが入力され、ユーザーは自分で一番下までスクロールし、さらにクリックしてデータを読み込むことができます。
クロームV84が登場するまでは良かったんですけどね...。
正確には、クロム84が問題なのです。最新のエッジはクロムカーネルを使用しており、同じ問題があるからです!
ある日、Load Moreをクリックした後、リストのコンテンツがリフレッシュされたように見え、次のような体験ができないというフィードバックをユーザーから受け取りました。
クリックするとさらに読み込み、ボタンの位置は常に同じまま、リストは勝手に埋まってスクロールアップ 。
このgifを見ると、問題ないと思うかもしれませんが、より多くのコンテンツが読み込まれる実際のシナリオでは、このようなセルフスクロールは、ユーザーが今見たアイテムの場所を突然見つけられなくなり、ユーザー体験を大きく混乱させます。
というか、このアプローチには一定のシナリオがありますが、動作の制御はフロントエンド開発者の判断に任せるべきでしょう?
(ナンセンスはちょっとやりすぎのようなので、記事の最後に直接引っ張って解決策を見たいのですが〜)
なぜクロムのバグ/機能だと判明したのですか?
私が受け取ったフィードバックによると、一部のユーザーでは時々、そして高い頻度で発生するとのことでした。ですから、最初はブラウザのレベルで考えず、自分のコードにロジックの穴がないかどうかを考えました。
いくつかのブラウザで試してみたところ、再現するブラウザがあることがわかりました。私のコードがシームレスであることを確認した後、私は反応を疑いました。
フレームワークとは無関係であることを確認するため、JavaScriptをオフにしてリスト要素を手動で親ノードにコピーしたところ、安定して再現できました。厳密を期すため、ネイティブコードで demo みましたが、やはり再現しました。つまり、問題はこれらのブラウザにあります。
夜は23時過ぎまで続いたので、先に寝ました。
翌日、目が覚めたとき、頭がすっきりしていました。
私の同僚は問題なくChrome 83をインストールしましたが、私自身の84は問題がありましたので、このChromeアップデートのせいのようです。
その後、同じような問題に遭遇した人がいないか、ウェブで検索してみました。偶然にも、その前日に同じ問題に遭遇したウェブユーザーがいました 。
最後に、アップデートのドキュメントを読んでください(私が知っているのは、Chrome 84が同じサイトのポリシーを調整したということだけです)。
Chrome 84 記事には、この機能についての記載はありません。
これは機能リストに載せるには小さすぎる機能変更のようですᙂより詳細なヒントを得るには、コミットログを見る必要があります!
コミットログ確認するしかないと思いますが、scrollキーワードを入力した後、何千もの結果がポップアップされ、本当にやる気が失せました。
影響の範囲
現在chromium 84カーネルを使用しているブラウザが影響を受けます:
- Chrome 84
- Edge 84
- Android Chrome 84
- Android Webview
iOSはないのですか? iOS版Chromeはchromiumカーネルを使っていないからです。
スクロールオフセットリセット
スクロールはブラウザが行うのですから、「スクロールする場所を覚えておいて、読み込みが終わったらスクロールして戻る」方がいいのではないでしょうか?
試してみたら、本当に効きました。
フルコード
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.items {
width: 100%;
}
.item {
margin-top: 10px;
height: 100px;
width: 100%;
background-color: #FF142B;
}
.btn {
width: 200px;
height: 44px;
margin-bottom: 40px;
background: #BCCFFF;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.05);
border: none;
border-radius: 22px;
font-size: 15px;
font-weight: 500;
color: #FF142B;
-webkit-transition: 150ms all;
transition: 150ms all;
}
</style>
<script>
function genRandomColor() {
const fn = () => parseInt(Math.random() * (255 + 1), 10)
return `rgb(${fn()},${fn()},${fn()})`
}
function showMore() {
let items = document.querySelector('.items')
let tmp = document.createElement('div')
// 現在の位置を覚えておく
const currentScrollTop = document.documentElement.scrollTop || document.body.scrollTop
tmp.className = "item"
tmp.style = `background-color: ${genRandomColor()}`;
items.appendChild(tmp)
// スクロールして位置を戻す
window.scrollTo({
top: currentScrollTop
})
}
function showMoreWithTimeout(){
setTimeout(showMore,10)
}
</script>
</head>
<body>
<div class="container">
<div class="items">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
<button class="btn" onclick="showMore()">クリックして拡大する</button>
</div>
</body>
</html>
それは可能ですが、まだいくつかの疑問があります:
- Chrome 84
- scrollToが1つのイベントループで複数回実行されるとどうなりますか?
- scrollToとscrollByが同時に実行されるとどうなりますか?
質問1はもっと複雑なので、まず他の質問をいくつか見てみましょう。
scrollToの複数回実行
window.addEventListener("scroll",()=>{console.log("scroll")})
window.scrollTo(0,50)
console.log(document.documentElement.scrollTop) // 50
window.scrollTo(0,150)
console.log(document.documentElement.scrollTop) // 150
// を出力する scroll
window.scrollTo(0,50)
window.requestAnimationFrame(()=>{
window.scrollTo(0,150)
})
// 2回出力する scroll
上記の例からわかるように
- scrollTo を実行するたびに scrollTop を読み取り、リアルタイムの応答を取得します。
- scrollToが何回実行されても、最後に実行されるスクロールイベントは1つだけで、最後のscrollTop位置が使用されます。
- rAFでscrollToを行うと、再びscrollイベントが発生します。
上記の結論は、HTML仕様のイベントループの記述からも学ぶことができます。イベントループでは、rAFでスクロールステップが実行されます。
しかし、インターフェイスの更新はイベントループの最後のステップなので、scrollTo が何回実行されても、最後にスクロールの更新が表示されるのは 1 回だけであることに注意しましょう。
setTimeoutとrAFの再実行の類似点と相違点
window.scrollTo(0,50)
window.setTimeout(()=>{
window.scrollTo(0,150)
},0)
// 2回出力する scroll
類似点は単純で、スクロールイベントが再びトリガーされることです。
違いは、setTimeoutは別のイベント・レンダリングなので、インターフェイスはスクロールの更新に2回反応することです。
scrollToとscrollByの話
違いは単純で、一方は絶対位置スクロール、一方は相対位置スクロールです。
その後、上記と同じタイミングでスクロールイベントが発生します。
- scrollTo(x1)を実行し、次にscrollBy(x2)を実行すると、x1+x2で終了します。
- scrollBy(x1)を実行し、次にscrollTo(x2)を実行すると、x2で終了します。
Chrome 84 内部スクロールのタイミングについて
調整された各要素のscrollTop出力は、リアルタイムで反応させることができるので、次のようなコードを書きました。
function getScrollTop(){
return document.documentElement.scrollTop || document.body.scrollTop
}
function showMore() {
let items = document.querySelector('.items')
let tmp = document.createElement('div')
const lastScrollTop = getScrollTop()
console.log("lastScrollTop:",lastScrollTop) // 529
tmp.className = "item"
tmp.style = `background-color: ${genRandomColor()}`;
items.appendChild(tmp)
console.log("currentScrollTop:",getScrollTop()) // 639
window.scrollTo(0,lastScrollTop)
console.log("changeScrollTop:",getScrollTop()) // 529
}
ご覧のように、リスト コンテナが子要素を追加すると、ブラウザは内部的に scrollTo などのメソッドを呼び出してオフセットを変更します。scrollTop は最後に復元されるので、このブラウザ内部の調整は影響を受けません。
問1回答~。
リアクトアプリケーションでの加工
上記はすべてネイティブコードの書き方ですが、ではリアクトコードではどうすればいいのでしょうか?
上記のコードをリアクトコンポーネントに変換します。
import React, { useState } from "react";
import "./styles.css";
function genRandomColor() {
const fn = () => parseInt(Math.random() * (255 + 1), 10);
return `rgb(${fn()},${fn()},${fn()})`;
}
const Item = ({ item }) => {
return <div className="item" style={{ backgroundColor: item.color }} />;
};
const getScrollTop = () => {
return document.documentElement.scrollTop || document.body.scrollTop;
};
const fetch = async () => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
color: genRandomColor()
});
}, 0);
});
};
export default function List() {
const [list, setList] = useState(
new Array(6).fill().map(v => ({ color: "red" }))
);
const showMore = async () => {
const scrollTop = getScrollTop();
let data = await fetch(); // 後続のコードが非同期に実行されるように約束をラップした。
// 非同期の状態変更はuseLayoutEffectと同期して実行される。 re-render
setList([...list, data]);
// オフセット・リセット
window.scrollTo({
top: scrollTop
});
};
return (
<div className="container">
<div className="items">
{list.map((item, i) => (
<Item item={item} key={i} />
))}
</div>
<button className="btn" onClick={showMore}>
クリックして拡大する
</button>
</div>
);
}
非同期に実行された状態変更は、useLayoutEffectを実行し、同期的に再レンダリングします。これは、プロミスやタイマーのようなreactによって制御されない非同期コードでは、状態変更メソッドを実行した後、内部で直接diffと再レンダリングを行い、すべての状態変更メソッドが実行されるまで待たずにを更新します。
同期関数の場合、setListの実行は非同期なので、すぐにwindow.scrollToを実行することはできません。
const showMore = () => {
const scrollTop = getScrollTop();
let data = { color: genRandomColor() };
setList([...list, data]);
//
window.scrollTo({
top: scrollTop
});
};
非同期と同期の両方の更新をサポートするメソッドを、再利用できるようにreactフックとしてカプセル化して書く必要があります。
function useScrollReset() {
const lastScrollTopRef = useRef(0);
const [scrollTop, setScrollTop] = useState(0);
useLayoutEffect(() => {
console.log("async: chromium v84+ need reset scroller");
window.scrollTo({
top: scrollTop
});
}, [scrollTop]);
const remainLastScrollTop = useCallback(() => {
lastScrollTopRef.current = getScrollTop();
}, []);
const resetScroller = useCallback(isAsyncStateChange => {
if (isAsyncStateChange) {
// 非同期の状態変化シナリオに最適である。
setScrollTop(lastScrollTopRef.current);
} else {
// 同期された状態変更シナリオに最適である。
console.log("sync: chromium v84+ need reset scroller");
window.scrollTo({
top: lastScrollTopRef.current
});
}
}, []);
return [remainLastScrollTop, resetScroller];
}
2つのメソッドをエクスポートします。最初のメソッド remainLastScrollTop は、リストにデータ項目が入力される前に使用され、現在のスクロール位置を記憶します。2番目のメソッドは、データ状態の変更が非同期かどうかに応じてブール値を渡します。
たとえば、上の2つの例は次のように使用します。
const showMore = async () => {
let data = await fetch();
remainLastScrollTop();
// ステータスは同期的に変更され、実装は再確認された。 render
setList([...list, data]);
// そこで、プログレスバーを直接調整するためにfalseを設定した
resetScroller(false);
};
const showMore = () => {
let data = { color: genRandomColor() };
remainLastScrollTop();
// 状態は非同期に変化するので、プログレスバーを調整するにはuseLayoutEffectまで待つ必要がある。
setList([...list, data]);
// となるようにスクロールの状態を変更するための真の設定である。 useLayoutEffect
resetScroller(true);
};
ブラウザ判定を追加
当初、「ブラウザのオフセットリセット」のせいで他のブラウザがより多くのパフォーマンスを使うのではないかと心配だったので、以下の判定を追加しました。
import Bowser from 'bowser'
const browserInfo = Bowser.getParser(window.navigator.userAgent)
const needReset = browserInfo.satisfies({
chrome: '>=84'
})
function useScrollReset() {
const lastScrollTopRef = useRef(0);
const [scrollTop, setScrollTop] = useState(0);
useLayoutEffect(() => {
console.log("async: chromium v84+ need reset scroller");
window.scrollTo({
top: scrollTop
});
}, [scrollTop]);
const remainLastScrollTop = useCallback(() => {
lastScrollTopRef.current = getScrollTop();
}, []);
const resetScroller = useCallback(isAsyncStateChange => {
if (isAsyncStateChange) {
// 非同期の状態変更シナリオに最適である。
setScrollTop(lastScrollTopRef.current);
} else {
// 同期された状態変更のシナリオに最適である。
console.log("sync: chromium v84+ need reset scroller");
window.scrollTo({
top: lastScrollTopRef.current
});
}
}, []);
return [remainLastScrollTop, needReset?resetScroller:()=>{}];
}
そこで、これは必要ないと思い、また、メンテナンス性が非常に悪いので、クロミウムでの国内ブラウザサポートの裏を返せば、ここを変更しなければならないかもしれないと思い、上記のようにEdgeには追加されていないので、削除しました。
アントデザインリストの新しいソリューション もっとデモを読み込む
解決策を見つける一方で、これらのコンポーネント・ライブラリが今回のアップデートで扱われるかどうか、もし扱われないのであれば、次のようなことが可能かどうかを教えていただければと思いました。
そこで、and designコンポーネントのドキュメントを開き、List load moreの demoみました。
驚くことに、このデモは通常の「クリックして続きを読む」として機能します。
公式チームがこの問題を発見し、修正したのはずっと前のことですか?
問題のコンポーネントのコミットログとソースコードを見た後、私はその考えを捨て、問題はこのデモにあると判断しました。
import React from 'react';
import ReactDOM from 'react-dom';
import 'antd/dist/antd.css';
import './index.css';
import { List, Avatar, Button, Skeleton } from 'antd';
import reqwest from 'reqwest';
const count = 3;
const fakeDataUrl = `https://./pi/?ts=${nt}&;=,,,&;fo`;
class LoadMoreList extends React.Component {
state = {
initLoading: true,
loading: false,
data: [],
list: [],
};
componentDidMount() {
this.getData(res => {
this.setState({
initLoading: false,
data: res.results,
list: res.results,
});
});
}
getData = callback => {
reqwest({
url: fakeDataUrl,
type: 'json',
method: 'get',
contentType: 'application/json',
success: res => {
callback(res);
},
});
};
onLoadMore = () => {
this.setState({
loading: true,
list: this.state.data.concat([...new Array(count)].map(() => ({ loading: true, name: {} }))),
});
this.getData(res => {
const data = this.state.data.concat(res.results);
this.setState(
{
data,
list: data,
loading: false,
},
() => {
// Resetting window's offsetTop so as to display react-virtualized demo underfloor.
// In real scene, you can using public method of react-virtualized:
// https://.///--------ed
window.dispatchEvent(new Event('resize'));
},
);
});
};
render() {
const { initLoading, loading, list } = this.state;
const loadMore =
!initLoading && !loading ? (
<div
style={{
textAlign: 'center',
marginTop: 12,
height: 32,
lineHeight: '32px',
}}
>
<Button onClick={this.onLoadMore}>loading more</Button>
</div>
) : null;
return (
<List
className="demo-loadmore-list"
loading={initLoading}
itemLayout="horizontal"
loadMore={loadMore}
dataSource={list}
renderItem={item => (
<List.Item
actions={[<a key="list-loadmore-edit">edit</a>, <a key="list-loadmore-more">more</a>]}
>
<Skeleton avatar title={false} loading={item.loading} active>
<List.Item.Meta
avatar={
<Avatar src="https://..//.ng" />
}
title={<a href="https://.gn">{..st}</>}
description="Ant Design, a design language for background applications, is refined by Ant UED Team"
/>
<div>content</div>
</Skeleton>
</List.Item>
)}
/>
);
}
}
ReactDOM.render(<LoadMoreList />, document.getElementById('container'));
ご覧のように、ボタンはデータがロードされると削除されます。また、ボタン自体もロード・プレースホルダーを使用しているため、ボタンを削除してもレイアウトがカクカクすることはありません。
コードが若干変更され、以下のようにスリム化されました:
import React from 'react';
import ReactDOM from 'react-dom';
import 'antd/dist/antd.css';
import './index.css';
import { List, Avatar, Button, Skeleton } from 'antd';
import reqwest from 'reqwest';
const count = 3;
const fakeDataUrl = `https://./pi/?ts=${nt}&;=,,,&;fo`;
class LoadMoreList extends React.Component {
state = {
loading: false,
data: [],
list: [],
};
componentDidMount() {
this.getData(res => {
this.setState({
data: res.results,
list: res.results,
});
});
}
getData = callback => {
reqwest({
url: fakeDataUrl,
type: 'json',
method: 'get',
contentType: 'application/json',
success: res => {
callback(res);
},
});
};
onLoadMore = () => {
this.setState({
loading: true
});
this.getData(res => {
const data = this.state.data.concat(res.results);
this.setState(
{
data,
list: data,
loading: false,
}
);
});
};
render() {
const { loading, list } = this.state;
const loadMore =
!loading ? (
<div
style={{
textAlign: 'center',
marginTop: 12,
height: 32,
lineHeight: '32px',
}}
>
<Button onClick={this.onLoadMore}>loading more</Button>
</div>
) : null;
return (
<>
<List
className="demo-loadmore-list"
itemLayout="horizontal"
dataSource={list}
renderItem={item => (
<List.Item
actions={[<a key="list-loadmore-edit">edit</a>, <a key="list-loadmore-more">more</a>]}
>
<Skeleton avatar title={false} loading={item.loading} active>
<List.Item.Meta
avatar={
<Avatar src="https://..//.ng" />
}
title={<a href="https://.gn">{..st}</>}
description="Ant Design, a design language for background applications, is refined by Ant UED Team"
/>
<div>content</div>
</Skeleton>
</List.Item>
)}
/>
{loadMore}
</>
);
}
}
ReactDOM.render(<LoadMoreList />, document.getElementById('container'));
クリックした後、ボタンは削除され、リストは一番下に表示されます。そして、リストに入力し、ボタンを表示した後、リストは元の位置に戻ります。
そして、ローディング状態を取り除いた後、つまり
const loadMore = <div
style={{
textAlign: 'center',
marginTop: 12,
height: 32,
lineHeight: '32px',
}}
>
<Button onClick={this.onLoadMore}>loading more</Button>
</div>;
クリックでさらに読み込むバグが復活し、削除ボタンによる新たなオフセットジッターが現れました。
ここから結論が導き出されます:
リストの下に要素がない場合、ブラウザはその上のコンテンツのスクロールオフセットを独自に調整することはありません。
正確には、「クリックなどのイベントのトリガーとなった要素は、リストコンテナと等しい親要素まで削除され、それ以外の「等しい下位要素」は処理されない」ということです。
そこで、ロード時にはボタンを非表示にし、ロード後に表示するようにします。しかし、ボタンを非表示にすると、レイアウトが変わってしまいます。スペースを占有する読み込みアイテムがない場合、データリストは最初に下にスクロールされ、これもエクスペリエンスに影響します。
データを取得した後、リストに追加している間はボタンを非表示にし、データを追加した直後にボタンを表示するというトリッキーな方法を思いつきました。これにより、ブラウザはレンダリング時に何も起こっていないと勘違いします。
ネイティブ・コードは以下の通り:
// getScrollTop()の値は3箇所とも同じである。
function showMore() {
let items = document.querySelector('.items')
let btn = document.querySelector('.btn')
let tmp = document.createElement('div')
const lastScrollTop = getScrollTop()
console.log("lastScrollTop:",lastScrollTop)
tmp.className = "item"
tmp.style = `background-color: ${genRandomColor()}`;
// 隠す
btn.style.display = "none";
items.appendChild(tmp)
console.log("currentScrollTop:",getScrollTop())
// 再表示
btn.style.display = "block";
console.log("changeScrollTop:",getScrollTop())
}
リアクトの書き方:
export default function List() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState(
new Array(6).fill().map(v => ({ color: "red" }))
);
useLayoutEffect(() => {
console.log("useLayoutEffect");
});
const showMore = async () => {
let data = await fetch();
// の状態を設定するたびに re-render
setLoading(true);
setList([...list, data]);
setLoading(false);
};
return (
<div className="container">
<div className="items">
{list.map((item, i) => (
<Item item={item} key={i} />
))}
</div>
{!loading && (
<button className="btn" onClick={showMore}>
クリックして拡大する
</button>
)}
</div>
);
}
Chrome v84の固定ビューポート効果をシミュレートする方法
このエフェクトは、モバイルのドロップダウン更新シーンに少し似ているので、このアップデートはモバイル向けかもしれませんね。
では、他のブラウザはどうやってこの機能をエミュレートするのでしょうか?
考えられる練習は
- 前後のレコードボタンのoffsetTopをクリックし、その差を計算し、scrollByを使用してオフセットをスクロールします。
2つ目のアプローチのデモは以下の通り。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.items {
width: 100%;
}
.item {
margin-top: 10px;
height: 100px;
width: 100%;
background-color: #FF142B;
}
.btn {
width: 200px;
height: 44px;
margin-bottom: 40px;
background: #BCCFFF;
box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.05);
border: none;
border-radius: 22px;
font-size: 15px;
font-weight: 500;
color: #FF142B;
-webkit-transition: 150ms all;
transition: 150ms all;
}
.bottom {
width: 100%;
height: 1200px;
}
</style>
<script>
function genRandomColor() {
const fn = () => parseInt(Math.random() * (255 + 1), 10)
return `rgb(${fn()},${fn()},${fn()})`
}
function getScrollTop() {
return document.documentElement.scrollTop || document.body.scrollTop
}
function showMore() {
let items = document.querySelector('.items')
let btn = document.querySelector('.btn')
let tmp = document.createElement('div')
const lastOffsetTop = btn.offsetTop
console.log("lastOffsetTop:", lastOffsetTop)
tmp.className = "item"
tmp.style = `background-color: ${genRandomColor()}`;
items.appendChild(tmp)
// この機能をrAFに入れることで、offsetTopの読み取りがリフローの原因となるのを防ぐことができる。この場合、ブラウザはアイテムをすぐにレンダリングし、後でスクロールを調整するとページがちらつくことになる。
requestAnimationFrame(() => {
const currentOffsetTop = btn.offsetTop
console.log("currentOffsetTop:", currentOffsetTop)
window.scrollBy({
top: currentOffsetTop - lastOffsetTop
})
})
}
function showMoreWithTimeout() {
setTimeout(showMore, 10)
}
</script>
</head>
<body>
<div class="container">
<div class="items">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
</div>
<button class="btn" onclick="showMore()">クリックして拡大する</button>
<div class="bottom">
ビューポートを修正した
</div>
</body>
</html>
これは、リフローの再描画を防ぐためにrAFで処理されることに注意してください。
他にもっと良い方法があれば、遠慮なくコメントやシェアをしてください。
概要
chrome v84+の新しいブラウザ固有の固定ビューポート機能により、「クリックしてさらに読み込む」シナリオが期待に添えない結果となりました。
この問題を解決するために、本稿では2つの解決策を提案します:
- スクロールオフセットリセット
- 以下の要素を非表示にします。
どちらも限界はありますが、ほとんどのシナリオに対応できます:
- 最初のスクロールバーはリセットされるので、リストが表示されていない状態でボタンをクリックした後、ユーザーがずっと下にスクロールし続けた場合、リストが表示されると、スクロールバーがリセットされ、これも悪い経験です。
- 第2戦のレイアウトはまだ完全には決まっておらず、今のところ何らかのレイアウト制限があるかどうかは不明です
当面は、カーネルのソースコードがこのバグをどのように処理しているかを確認し、 demo 進行状況を見守るのが良いでしょう。
現在の状況:正式決定
最後に、念のため、この機能の実装を調査する試みも行われました。




