I.はじめに
私は最近パフォーマンスの最適化を行っていますが、具体的な最適化方法はインターネット上にたくさんあるので、ここでは繰り返しません。
パフォーマンスの最適化は、コード レベル、構築レベル、ネットワーク レベルの次元に分けることができます。
この記事では主にフロントエンドのパフォーマンスをコードレベルから考察し、主に次の 4 つのセクションに分かれています。
JS の代わりに CSS を使用する
JSの詳細な分析
フロントエンドアルゴリズム
コンピューターの最下層
2. JSの代わりに CSS を使用する
ここでは主にアニメーションとCSSコンポーネントの2つの側面から紹介します。
CSSアニメーション
CSS2 が登場する前は、単純なアニメーションですら JS を介して実装する必要がありました。たとえば、以下の赤い四角の水平方向の動きです。
対応するJSコード:
let redBox = document.getElementById('redBox')
let l = 10
setInterval(() => {
l+=3
redBox.style.left = `${l}px`
}, 50)
1998 年の CSS2 仕様ではいくつかのアニメーション プロパティが定義されていましたが、当時のブラウザー テクノロジの制限により、これらの機能は広くサポートされず、適用されませんでした。
CSS3 が導入されるまで、CSS アニメーションはより完全にサポートされていました。同時に、CSS3 ではさらに多くのアニメーション効果も導入されており、CSS アニメーションは今日の Web 開発で広く使用されています。
CSS3 でどのようなアニメーションを実現できるのか、いくつかの例を示します。
トランジション - トランジションは、CSS3 で一般的に使用されるアニメーション効果の 1 つであり、要素の特定の属性を変換することにより、要素は一定時間内に 1 つの状態から別の状態にスムーズに遷移できます。
アニメーション - アニメーションは、CSS3 でよく使用されるもう 1 つのアニメーション効果です。要素に複雑なアニメーション効果を追加するために使用されます。一連のアニメーション シーケンスは、キーフレーム (@keyframes) を通じて定義できます。
変換 - 変換は、回転、スケーリング、移動、ベベル、その他の効果を含む 2D/3D グラフィックス変換効果を実現するために CSS3 で使用されるテクノロジーです。
上記の例を次のように CSS コードに書き換えます。
#redBox {
animation: mymove 5s infinite;
}
@keyframes mymove
{
from {left: 0;}
to {left: 200px;}
}
スタイルを使用しても同じ効果が得られるので、ぜひ試してみてください。
CSS アニメーションは依然として開発と改良が続けられていることに注意してください。新しいブラウザー機能と CSS バージョンの出現に伴い、CSS アニメーションの機能は、ますます複雑化するアニメーションのニーズとより良いユーザー エクスペリエンスを満たすために常に追加および最適化されています。
CSSコンポーネント
一部のよく知られたコンポーネント ライブラリでは、Vant の Space コンポーネントなど、一部のコンポーネントのプロパティのほとんどが CSS スタイルを変更することによって実装されています。
小道具 |
関数 |
CSS スタイル |
方向 |
間隔方向 |
フレックス方向: 列; |
整列する |
位置合わせ |
整列項目: xxx; |
埋める |
Space をブロックレベル要素にして親要素全体を埋めるかどうか |
ディスプレイ: フレックス; |
包む |
自動的に行を折り返すかどうか |
フレックスラップ: ラップ; |
もう 1 つの例は、Ant Design の Space コンポーネントです。
小道具 |
関数 |
CSS スタイル |
整列する |
位置合わせ |
整列項目: xxx; |
方向 |
間隔方向 |
フレックス方向: 列; |
サイズ |
間隔のサイズ |
ギャップ:xxx; |
包む |
自動的に行を折り返すかどうか |
フレックスラップ: ラップ; |
このタイプのコンポーネントは、SCSS mixin 実装に完全にカプセル化できます (LESS にも同じことが当てはまります)。これにより、プロジェクトの構築ボリュームを削減できるだけでなく (gzip 後の 2 つのライブラリの Space コンポーネントのサイズは 5.4k と 22.9k です) k) だけでなく、パフォーマンスも向上します。
コンポーネント ライブラリ内のコンポーネントのボリュームを表示するには、リンク https://bundlephobia.com/ にアクセスしてください。
たとえば、次のスペース ミックスイン:
/*
* 间距
* size: 间距大小,默认是 8px
* align: 对齐方式,默认是 center,可选 start、end、baseline、center
* direction: 间距方向,默认是 horizontal,可选 horizontal、vertical
* wrap: 是否自动换行,仅在 horizontal 时有效,默认是 false
*/
@mixin space($size: 8px, $direction: horizontal, $align: center, $wrap: false) {
display: inline-flex;
gap: $size;
@if ($direction == 'vertical') {
flex-direction: column;
}
@if ($align == 'center') {
align-items: center;
}
@if ($align == 'start') {
align-items: flex-start;
}
@if ($align == 'end') {
align-items: flex-end;
}
@if ($align == 'baseline') {
align-items: baseline;
}
@if ($wrap == true) {
@if $direction == 'horizontal' {
flex-wrap: wrap;
}
}
}
同様のコンポーネントには、グリッド、レイアウトなどが含まれます。
アイコンについて話しましょう。以下は Ant Design アイコン コンポーネントの最初のスクリーンショットです。HTML + CSS だけを使用して簡単に実装できるものがたくさんあります。
実装のアイデア:
スタイルのみを使用して実装を優先する
スタイルだけでは不十分な場合は、最初にタグを追加し、このタグとその 2 つの疑似要素::before および::after を通じて実装します。
1 つのラベルでは不十分な場合は、ラベルを追加することを検討してください。
たとえば、4 方向をサポートする実線三角形を実装するには、わずか数行のスタイルで実現できます (上のスクリーンショットには 4 つのアイコンが表示されています)。
/* 三角形 */
@mixin triangle($borderWidth: 10, $shapeColor: #666, $direction: up) {
width: 0;
height: 0;
border: if(type-of($borderWidth) == 'number', #{$borderWidth} + 'px', #{$borderWidth}) solid transparent;
$doubleBorderWidth: 2 * $borderWidth;
$borderStyle: if(type-of($doubleBorderWidth) == 'number', #{$doubleBorderWidth} + 'px', #{$doubleBorderWidth}) solid #{$shapeColor};
@if($direction == 'up') {
border-bottom: $borderStyle;
}
@if($direction == 'down') {
border-top: $borderStyle;
}
@if($direction == 'left') {
border-right: $borderStyle;
}
@if($direction == 'right') {
border-left: $borderStyle;
}
}
つまり、CSS で実装できるものは JS を必要とせず、パフォーマンスが優れているだけでなく、複数のテクノロジー スタック、さらには複数のターミナルにまたがって実装できます。
3. JSの詳細な分析
CSS を紹介した後、主に基本的なステートメントとフレームワークのソース コードの 2 つの側面から JS を見てみましょう。
if-else ステートメントの最適化
まず、CPU が条件ステートメントを実行する方法を理解します。次のコードを参照してください。
const a = 2
const b = 10
let c
if (a > 3) {
c = a + b
} else {
c = 2 * a
}
CPU の実行フローは次のとおりです。
命令 0102 が実行されると、条件 a > 3 が満たされないため、命令 0104 に直接ジャンプして実行されることがわかります。さらに、コンパイル中に a が 3 を超えることができないことが判明した場合、コンピューターは非常に賢いので、命令 0103 が直接削除され、命令 0104 が次の命令となり、順番に直接実行されます。これがコンパイラの最適化です。
トピックに戻りますが、次のコードがあるとします。
function check(age, sex) {
let msg = ''
if (age > 18) {
if (sex === 1) {
msg = '符合条件'
} else {
msg = ' 不符合条件'
}
} else {
msg = '不符合条件'
}
}
ロジックは非常に単純です。年齢 > 18 かつ性別 == 1 の人々をフィルタリングすることです。コードにはまったく問題ありませんが、冗長すぎます。CPU の観点からは、2 つのジャンプ操作が必要です。 age > 18 の場合、内側の if-else を入力して判定を続行します。これは、再度ジャンプすることを意味します。
実際、このロジックを直接最適化できます (通常はこれを行いますが、知っていてもその理由は分からない場合があります)。
function check(age, sex){
if (age > 18 && sex ==1) return '符合条件'
return '不符合条件'
}
したがって、ロジックを早期に終了できる場合は、CPU ジャンプを減らすために早期に終了します。
Switch文の最適化
実際、switch ステートメントと if-else ステートメントには、記述方法が異なることを除けば大きな違いはありませんが、switch ステートメントには配列という特別な最適化があります。
次のコードを参照してください。
function getPrice(level) {
if (level > 10) return 100
if (level > 9) return 80
if (level > 6) return 50
if (level > 1) return 20
return 10
}
これを switch ステートメントに変更します。
function getPrice(level) {
switch(level)
case 10: return 100
case 9: return 80
case 8:
case 7:
case 6: return 50
case 5:
case 4:
case 3:
case 2:
case 1: return 20
default: return 10
}
違いがないように見えますが、実際には、コンパイラーはそれを配列に最適化します。配列の添字は 0 から 10 です。異なる添字に対応する価格が戻り値になります。つまり、次のようになります。
また、配列はランダム アクセスをサポートしており、非常に高速であることがわかっているため、コンパイラーによるスイッチの最適化により、プログラムの実行効率が大幅に向上し、コマンドを 1 つずつ実行するよりもはるかに高速になります。
では、他にどのような if-else ステートメントを書けばよいのでしょうか?すべてのスイッチを書けばよいのでしょうか?
いいえ!コンパイラーによる switch の最適化は条件付きであるため、コードがコンパクトであること、つまり連続的であることが必要です。
どうしてこれなの?配列を使用して最適化したいためです。コンパクトではない場合、たとえば、コードが 1、50、51、101、110 の場合、長さ 110 の配列を作成して格納します。これらの位置のみ便利です、スペースの無駄ではありませんか!
したがって、switch を使用するときは、コードがコンパクトな数値型であることを確認するように努めます。
ループ文の最適化
実はループ文は条件文と似ていますが書き方が異なり、ループ文の最適化のポイントは主に命令を減らすことです。
まずは2級の書き方を見てみましょう。
function findUserByName(users) {
let user = null
for (let i = 0; i < users.length; i++) {
if (users[i].name === '张三') {
user = users[i]
}
}
return user
}
配列の長さが 10086 で、最初の人物が Zhang San と呼ばれる場合、次の 10085 回の走査は無駄になり、CPU は実際には人間として使用されていません。
次のように書くことはできないでしょうか:
function findUserByName(users) {
for (let i = 0; i < users.length; i++) {
if (users[i].name === '章三') return users[i]
}
}
これは、書き込み効率が高く、可読性が高く、早期に終了できる場合は早期に終了するという上記のロジックとも一致します。CPU は皆さんに直接感謝します。
実際、ここには最適化できるものがあります。つまり、毎回アクセスすることなく配列の長さを抽出できます。
function findUserByName(users) {
let length = users.length
for (let i = 0; i < length; i++) {
if (users[i].name === '章三') return users[i]
}
}
これは少し細かいことのように思えるかもしれませんが、確かにその通りですが、パフォーマンスを考慮すると、それでも便利です。たとえば、一部のコレクションの size() 関数は単純な属性アクセスではなく、毎回計算する必要があります。このシナリオは、多くの関数呼び出しのプロセスを節約するため、大きな最適化です。これにより、間違いなくコードの効率が向上します。特に量的変化が質的変化を招きやすいループ文の場合、この細部から差が広がります。
関数呼び出しプロセスのリファレンス:
対応するコードは次のとおりです。
let a = 10
let b = 11
function sum (a, b) {
return a + b
}
いくつかの基本的なステートメントについて説明した後、私たちがよく使用するフレームワークの内部を見てみましょう。多くの場所でのパフォーマンスは調査する価値があります。
差分アルゴリズム
Vue も React も仮想 DOM を使用しているため、更新の際には新旧の仮想 DOM を比較してください。最適化を行わないと、2 つのツリーを直接厳密に差分した場合の時間計算量は O(n^3) となり、まったく使用できません。したがって、Vue と React は、diff アルゴリズムを使用して仮想 DOM を最適化する必要があります。
Vue2 - 両端の比較:
上の図と同様:
4 つの変数を定義します: oldStartIdx、oldEndIdx、newStartIdx、newEndIdx
oldStartIdx と newStartIdx が等しいかどうかを判断します
oldEndIdx と newEndIdx が等しいかどうかを判断します
oldStartIdx と newEndIdx が等しいかどうかを判断します
oldEndIdx と newStartIdx が等しいかどうかを判断します
同時に、oldStartIdx と newStartIdx は右に移動し、oldEndIdx と newEndIdx は左に移動します。
Vue3 - 最長の増加サブシーケンス:
プロセス全体は、Vue2 の両端の比較に基づいて再度最適化されます。たとえば、上のスクリーンショットは次のとおりです。
まず両端の比較を実行し、最初の 2 つのノード (A と B) と最後のノード (G) が同じであり、移動する必要がないことを確認します。
最も長く増加するサブシーケンス C、D、E (古い子と新しい子の両方を含むノードのグループであり、最も長い順序は変わっていない) を見つけます。
内部操作を行わずにサブシーケンスを全体として扱います。F を先頭に移動し、H を後ろに挿入するだけです。
React - 右のみにシフト:
上記のスクリーンショットの比較プロセスは次のとおりです。
Old をトラバースし、対応する添字マップを保存します
New をトラバースすると、b の添字は 1 から 0 に変化しますが、移動しません (右シフトではなく左シフトです)。
c の添字は 2 から 1 に変化しますが、移動しません (これも右ではなく左に移動します)。
a の添字は 0 から 2 に変化し、右に移動し、b と c の添字は 1 ずつ減ります。
d と e の位置は変更されていないため、移動する必要はありません。
つまり、どのようなアルゴリズムが使用されるとしても、その原則は次のとおりです。
レベル間ではなく、同じレベルでのみ比較してください
タグが異なる場合は、タグを削除して再構築します (内部詳細は比較されなくなります)。
子ノードはキーによって区別されます(キーの重要性)
最終的に、実際のプロジェクトで使用できるようになるまでに、時間の複雑さは O(n) まで削減されることに成功しました。
setState は本当に非同期ですか?
多くの人は setState が非同期であると考えていますが、次の例を見てください。
clickHandler = () => {
console.log('--- start ---')
Promise.resolve().then(() => console.log('promise then'))
this.setState({val: 1}, () => {console.log('state...', this.state.val)})
console.log('--- end ---')
}
render() {
return <div onClick={this.clickHandler}>setState</div>
}
実際の印刷結果:
非同期の場合、状態の出力はマイクロタスク Promise の後に実行する必要があります。
この理由を説明するには、まず JSX のイベント メカニズムを理解する必要があります。
onClick={() => {}} などの JSX のイベントは、実際には合成イベントと呼ばれ、私たちがよく呼ぶカスタム イベントとは異なります。
// 自定义事件
document.getElementById('app').addEventListener('click', () => {})
合成イベントはルート ノードにバインドされており、前操作と後操作があります。上記の例を見てください。
function fn() { // fn 是合成事件函数,内部事件同步执行
// 前置
clickHandler()
// 后置,执行 setState 的 callback
}
関数 fn があり、その中のイベント (setState を含む) が同期的に実行されると想像できます。fn が実行された後、非同期イベント、つまり Promise.then の実行が開始されます。これは出力された結果と一致しています。
では、なぜ React はこのようなことを行うのでしょうか?
パフォーマンスを考慮して、状態を複数回変更する必要がある場合、React は最初にこれらの変更をマージし、変更されるたびに DOM がレンダリングされるのを避けるために、マージ後に DOM を 1 回だけレンダリングします。
したがって、setState の本質は同期であり、日常的な「非同期」は厳密なものではありません。
4. フロントエンドアルゴリズム
私たちの日々の開発について話した後、フロントエンドでのアルゴリズムの適用について話しましょう。
注意: アルゴリズムは通常、大量のデータを対象に設計されており、日常の開発とは異なります。
値型を使用できる場合は、参照型は必要ありません。
まず質問を見てみましょう。
1 ~ 10000 の範囲のすべての対称数値を検索します (例: 0、1、2、11、22、101、232、1221...)。
アイデア 1 - 配列の反転と比較を使用する: 数値を文字列に変換してから配列に変換し、配列を反転してから文字列に結合し、前後の文字列を比較します。
function findPalindromeNumbers1(max) {
const res = []
if (max <= 0) return res
for (let i = 1; i <= max; i++) {
// 转换为字符串,转换为数组,再反转,比较
const s = i.toString()
if (s === s.split('').reverse().join('')) {
res.push(i)
}
}
return res
}
アイデア 2 - 文字列の先頭と末尾の比較: 数値を文字列に変換し、文字列の先頭と末尾の文字を比較します。
function findPalindromeNumbers2(max) {
const res = []
if (max <= 0) return res
for (let i = 1; i <= max; i++) {
const s = i.toString()
const length = s.length
// 字符串头尾比较
let flag = true
let startIndex = 0 // 字符串开始
let endIndex = length - 1 // 字符串结束
while (startIndex < endIndex) {
if (s[startIndex] !== s[endIndex]) {
flag = false
break
} else {
// 继续比较
startIndex++
endIndex--
}
}
if (flag) res.push(res)
}
return res
}
アイデア 3 - フリップ数値を生成する: % と Math.floor を使用してフリップ数値を生成し、前後の数値を比較します (全体的に数値を操作し、文字列型は使用しません)。
function findPalindromeNumbers3(max) {
const res = []
if (max <= 0) return res
for (let i = 1; i <= max; i++) {
let n = i
let rev = 0 // 存储翻转数
// 生成翻转数
while (n > 0) {
rev = rev * 10 + n % 10
n = Math.floor(n / 10)
}
if (i === rev) res.push(i)
}
return res
}
パフォーマンス分析: 高速化
アイデア1 - O(n)のようですが、配列の変換と演算に時間がかかるので遅いです
アイデア 2 VS アイデア 3 - 数値をより速く操作する (コンピューターのプロトタイプは電卓です)
つまり、データ構造、特に配列などの順序付けされた構造を変換しないようにし、リバースなどの組み込み API を使用しないようにしてください。複雑さを特定するのは困難です。数値演算が最も高速で、次に文字列が続きます。
「低レベル」コードを使用してみる
早速次の質問に行きましょう。
文字列を入力し、大文字と小文字を入れ替えます。
たとえば、文字列 12aBc34 を入力し、文字列 12AbC34 を出力します。
アイデア 1 - 正規表現を使用します。
function switchLetterCase(s) {
let res = ''
const length = s.length
if (length === 0) return res
const reg1 = /[a-z]
const reg2 = /[A-Z]
for (let i = 0; i < length; i++) {
const c = s[i]
if (reg1.test(c)) {
res += c.toUpperCase()
} else if (reg2.test(c)) {
res += c.toLowerCase()
} else {
res += c
}
}
return res
}
アイデア 2 - ASCII コードで判断します。
function switchLetterCase2(s) {
let res = ''
const length = s.length
if (length === 0) return res
for (let i = 0; i < length; i++) {
const c = s[i]
const code = c.charCodeAt(0)
if (code >= 65 && code <= 90) {
res += c.toLowerCase()
} else if (code >= 97 && code <= 122) {
res += c.toUpperCase()
} else {
res += c
}
}
return res
}
パフォーマンス分析: 前者は正則化を使用するため、後者よりも遅くなります。
したがって、「低レベル」コードを使用し、糖衣構文、高レベル API、または正規表現を慎重に使用するようにしてください。
5. コンピューターの最下層
最後に、フロントエンドが理解する必要があるコンピューターの基礎的な側面のいくつかについて話しましょう。
「メモリ」からデータを読み取る
私たちがよく言うこと: メモリからデータを読み取るということは、データをレジスタに読み取ることを意味しますが、データはメモリからレジスタに直接読み込まれるのではなく、最初にキャッシュに読み込まれてからレジスタに読み込まれます。
レジスタは CPU 内にあり、CPU の一部でもあるため、CPU はレジスタからのデータの読み取りと書き込みを非常に高速に行います。
どうしてこれなの?メモリからのデータの読み取りが遅すぎるためです。
このように理解できます: CPU は最初にデータをキャッシュに読み込んで使用し、実際に使用するときにキャッシュからレジスタを読み取り、レジスタが使用されるとデータをキャッシュに書き込み、その後、キャッシュは適切なタイミングでデータをメモリに書き込みます。
CPUの動作速度は非常に速いですが、メモリからのデータの読み出しは非常に遅いです。毎回メモリからデータを読み書きすると、必然的にCPUの動作速度が遅くなります。実行には100秒、99秒かかる場合があります。データの読み取りに数秒かかります。この問題を解決するために、CPU とメモリの間にキャッシュを置き、CPU とキャッシュ間の読み書き速度が非常に速く、CPU はキャッシュにデータを読み書きするだけです。キャッシュとキャッシュ メモリ間でデータを同期する方法。これにより、メモリの読み取りと書き込みが遅いという問題が解決されます。
2進ビット演算
バイナリ ビット演算を柔軟に使用すると速度が向上するだけでなく、バイナリを上手に使用するとメモリも節約できます。
数値 n が与えられた場合、n が 2 の n 乗であるかどうかをどのように判断しますか?
とても簡単です、残りを求めるだけです。
function isPowerOfTwo(n) {
if (n <= 0) return false
let temp = n
while (temp > 1) {
if (temp % 2 != 0) return false
temp /= 2
}
return true
}
コードに問題はありませんが、十分ではありません。以下のコードを見てください。
function isPowerOfTwo(n) {
return (n > 0) && ((n & (n - 1)) == 0)
}
console.time と console.timeEnd を使用して実行速度を比較できます。
ソース コードによっては、多くのフラグ変数が存在する場合もありますが、これらのフラグに対してビット単位の AND またはビット単位の OR 演算を実行して、フラグを検出し、特定の機能が有効かどうかを判断します。なぜ彼はブール値を使用しないのでしょうか? 非常にシンプルで効率的でメモリを節約できます。
たとえば、Vue3 ソース コードの次のコードでは、ビット単位の AND とビット単位の OR を使用するだけでなく、左シフトも使用します。
export const enum ShapeFlags {
ELEMENT = 1,
FUNCTIONAL_COMPONENT = 1 << 1,
STATEFUL_COMPONENT = 1 << 2,
TEXT_CHILDREN = 1 << 3,
ARRAY_CHILDREN = 1 << 4,
SLOTS_CHILDREN = 1 << 5,
TELEPORT = 1 << 6,
SUSPENSE = 1 << 7,
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}
if (shapeFlag & ShapeFlags.ELEMENT || shapeFlag & ShapeFlags.TELEPORT) {
...
}
if (hasDynamicKeys) {
patchFlag |= PatchFlags.FULL_PROPS
} else {
if (hasClassBinding) {
patchFlag |= PatchFlags.CLASS
}
if (hasStyleBinding) {
patchFlag |= PatchFlags.STYLE
}
if (dynamicPropNames.length) {
patchFlag |= PatchFlags.PROPS
}
if (hasHydrationEventBinding) {
patchFlag |= PatchFlags.HYDRATE_EVENTS
}
}
6. 結論
この記事では、フロントエンドのパフォーマンスをコード レベルから詳細に説明しています。
JSの基礎知識を徹底分析
フレームワークのソースコード
幅の寸法もあります。
CSSアニメーション、コンポーネント
アルゴリズム
コンピューターの最下層
皆さんがフロントエンドのパフォーマンスに関する視野を広げるのに役立つことを願っています。この記事に興味がある場合は、ディスカッションのためにメッセージを残してください。
-終わり-