關於增進前端網頁的載入速度


Posted by Wangpoching on 2021-10-30

關於優化網站的載入一職,一直是水很深的一門學問,這次我用了 yakim-shu 製作的超級慢速網站來練習優化流程,真的學到了許多東西。

待優化網站原始碼

https://github.com/Lidemy/lazy-hackathon

WebPageTest報告網址

原始報告

img

最終報告

img


第一步:刪除冗餘的程式碼

index.html

  • 把出師表刪掉
  • 刪掉註解
  • 刪除用不到的外部資源
    1. CSS
      • 刪除 material-icons.css 的 link
    2. JS
      • bootstrap 的元件有用到要保留
      • 網站的幻燈片使用 slick 套件所以保留
      • jquery 與 slick 相依要保留
      • 網站的打字效果用到 typed.js 所以保留
      • vue/angular/glide/sweetalert/material-component-web.js 沒用到都刪除

index.js

幫 index.js 瘦身,裡面太多奇怪的東西了,一大堆分類的演算法,以下列出要保留還有修正的部分。

  • typed 功能
  • slick 功能
  • hashchange 功能
  • scroll 以後 nav 會縮小的功能
  • addEventListener 的 capture 參數預設值是 false,可以刪掉
    其他的放心刪掉。

結果

因為需要載入資源的變少了,Document Complete Time 大概快了一秒,不過 domContentLoaded 從 2.7 秒進步到 1.37 秒,也就是說完成 DOM Tree 以及 CSSOM Tree 解析的時間變快了,但因為圖片太肥大,所以要完整載完網頁還要很久

img

第二步:估計 CRP 的 number / size / path number

CRP(Crital Rendering Path) 會影響到第一次渲染的時間,所以先對 CRP 的幾個面向進行評估。

  • 數量
  • 大小
  • 需要幾次同步的發起請求

CRP 數量 ( 11 個 )

  <link href="https://fonts.googleapis.com/css?family=Arvo|Noto+Sans+TC&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="./css/bootstrap.css">
  <link rel="stylesheet" href="./js/slick/slick.css">
  <link rel="stylesheet" href="./js/slick/slick-theme.css">
  <link rel="stylesheet" href="./css/style.css">
  <script src="./js/jquery-3.4.1.js"></script>
  <script src="./js/bootstrap.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/typed.js/2.0.10/typed.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.9.0/slick.js"></script>
  <script src="./js/index.js"></script>

5 個外部的 CSS 以及 5 個外部的 Javascript 檔案,再加上 HTML 本身,所以CRP 的數量是 11 個

CRP 大小 ( 約等於 203 KB )

img

  • HTML: 4.0 KB
  • CSS
    • style.css : 4.7 KB
    • slick-theme.css : 1.1 KB
    • slick.css : 707 B
    • bootstrap.css : 26.3 KB
    • css 字體 : 33.5 KB
  • JS
    • index.js : 916 B
    • slick.js : 13.6 KB
    • typed.js : 6.9 KB
    • bootstrap.js : 26.0 KB
    • jquery-3.4.1.js : 84.9 KB

CRP 同步請求數量 ( 2 個 )

img

這張圖模擬了第一次渲染的流程,第一次送出 request 獲取 html,接著在解析 DOM 文件時,會對找到的外部 CSS / JS 送出 request ,因為這些外部資源不用互相等待下載完成,所以合計為一次同步請求,所以瀏覽起總共需要發起兩次的同步請求。

第三步:初步壓縮資源 ( 約等於 81 KB )

要加快第一次渲染的速度,可以先從讓關鍵資源的 Bytes 減少。

  • html
    • 使用 gulp-htmlmin
  • css
    • 使用 sass, node-sass 轉成 css
    • 使用 postcss, autoprefixer 當作 css loader 並且加上前綴(適用個別瀏覽器的前綴)
    • 使用 cssnano 壓縮 css,建議在開發的時候先不要用,發布時再用
    • 記得要自己幫 mask 相關的屬性加上前綴,postcss / autoprefixer 似乎不支援

以下附上 gulp.file。

const { src, dest } = require('gulp')
const sass = require('gulp-sass')(require('node-sass'));
const postcss = require('gulp-postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano'); // 載入 cssnano 套件
const uglify = require("gulp-uglify");
const htmlmin = require('gulp-htmlmin');

function css(cb) {
  return src('./src/*.scss')
    .pipe(sass().on('error', sass.logError))
    .pipe(postcss([autoprefixer(), cssnano()])) // 陣列方式啟用插件
    .pipe(dest('./dist'));
}

function js(cb) {
  return src("./src/*.js")
    .pipe(uglify())
    .pipe(dest("./dist"));
}

function html(cb) {
  return src("./src/*.html")
    .pipe(htmlmin({ collapseWhitespace: true }))
    .pipe(dest("./dist"));  
}

exports.css = css
exports.js = js
exports.html = html

再次實測資源的大小,結果如下。

img

  • HTML: 4.0 KB
  • CSS
    • style.css : 4.7 KB => 3.4 KB
    • slick-theme.css : 1.1 KB => 895 B
    • slick.css : 707 B => 735 B
    • bootstrap.css : 26.3 KB => 22.8 B
    • css 字體 : 33.5 KB (沒壓縮)
  • JS
    • index.js : 916 B => 398 B
    • slick.js : 13.6 KB => 2.8 KB
    • typed.js : 6.9 KB => 3.5 KB
    • bootstrap.js : 26.0 KB => 15.5 KB
    • jquery-3.4.1.js : 84.9 KB => 31.3 KB

最後總計大約資源大小是 81 KB,與原來的 203 KB 相比,節約了大約 60%

結果

因為需要載入資源的變小了,domContentLoaded 從 2.7 秒繼續進步到 0.897 秒,但因為圖片還沒處理,所以要完整載完網頁還要 55 秒左右。

img

第四步 調整圖片大小

重新取樣

想要把圖片尺寸到正常而且不失真的狀態,可以觀察圖片在網頁最大的像素大小。舉例來說,在 dev tool 將可視區域拉寬,觀察參賽隊伍的圖片,會發現最大的寬度是 255px。

考慮到 retina 螢幕解析度為兩倍的情況,可以將參賽隊伍的圖片調整成 510px 而不會失真。

這邊使用 photoshop 來重新取樣,底下列出重新調整大小的圖片:

圖片區

  • 參賽隊伍區的圖片,最大寬度是 255px
    • 800px => 510px
  • 評審介紹區幻燈片的大圖,最大寬度是 750px
    • 1920px => 1500px
  • 評審介紹區幻燈片的小圖,最大寬度是 92px
    • 800px => 184px

icon

  • 各標題旁邊的 icon 圖片,寬度是 20px
    • 128px => 40px
  • 歷屆成績區的 icon 圖片,寬度是 80px
    • 512px => 160px
  • 社群分享的 icon 圖片,寬度是 30px
    • 128px => 60px

其他

  • nav-bar 的 logo,寬度是 160px
    • 520px => 320px
  • add-circular-outline-button 有兩個區域用到,比較大的寬度是 20px
    • 128px => 40px

背景大圖轉檔

滿版的圖片不重新取樣,因為瀏覽網頁的裝置可能很大,但是可以將 png 轉成 jpg,因為採用不同的方式編碼,所以可以瘦身。

到此為止 image 資料夾大小從 29.7 MB 下降到 16.3 MB,節約了將近一半的大小!

所有圖片再次用 tinypng 壓縮

tinypng 的原理是把相近色給併為一個顏色,這也是一個可以有效降低圖片資料量的方法! 到 tinypng 的官網把所有圖再壓過一次。

這次 image 資料夾的大小下降到 3.7 MB,較上個步驟又節約了 85% 的大小,相近色合併的效果超級威。

結果

再次跑 WebPageTest,結果如下:

img

整個網頁載入的時間下降到只要 10 秒左右,原本要 55 秒,進步神速!

第五步 Lazy loading

所謂的 Lazy Loading 是指當資源進入 viewport 時才會下載,有許多工具可以使用,因為原本就有引入了 jQuery,所以就使用 jQuery 提供的 Lazy loading

img tag

  1. 引入 <script src="./js/jquery.lazy.min.js"></script>
  2. 把 img 的 class 新增 lazy
  3. 把 img 的 src 屬性名稱本身改成 data-src

EX.src = "xxx" => data-src = "xxx"

  1. 在 DOM 解析完後執行
  $('.lazy').Lazy();

背後的原理是因為瀏覽器認不出 data-src,等到圖片進入 viewport 會觸發事件,這時候 jQuery 才會把 data-src 換成 src 屬性,瀏覽器便會在這個時候下載圖片。

css 背景圖片

這個稍微麻煩一些,但原理跟 img 相同。

  1. 在容器上加上 id。ex. id="bg-image"
  2. 在 css 添加規則使得在有該 id 下沒有背景圖。

EX. #bg-image { background-image:none }

  1. 在 DOM 解析完後執行
  const lazyloadBackgrounds = document.querySelectorAll('#bg-image');
  var backgroundObserver = new IntersectionObserver(function(entries, observer) {
    entries.forEach(function(entry) {
      if (entry.isIntersecting) {
        var background = entry.target;
        background.removeAttribute('id');
        backgroundObserver.unobserve(background);
      }
    });
  });

  lazyloadBackgrounds.forEach(function(background) {
    backgroundObserver.observe(background);
  });

在容器進入 viewport 時會被偵測到,這時候把 id 給刪除,原本被 { background: none } 蓋過的規則就會被採用,瀏覽器在這時候便會下載背景圖片。

最後再把 mask-image 也比照辦理就完成所有圖片的 Lazy Loading 了。

結果

推測這次的優化應該會效果顯著,最後結果如下:

img

只要 4 秒! 把圖片 Lazy Loading 又優化了一半以上的速度!

第六步 使用 webp 圖檔以及 Lazyloading

webp 格式的圖檔在大部分的瀏覽器都有支援,把 png 以及 jpg 檔換成 webp 能再大幅壓縮檔案大小。

轉檔

  1. 使用 npm 套件 Imagemin WebP
  2. 基礎語法

    npx cwebp a.jpg -o a.webp
    

    將所有圖轉成 webp 後 image 資料夾的大小下降到 1.8 MB,又將資料夾的大小變為原本的一半。

img tag 的 Lazy Loading

因為不是所有瀏覽器都支援 webp,所以可以利用 picture tag 讓瀏覽器自行判斷要載入 webp 或是 png/jpg,可以參考 MDN 的說明

  1. 引入新的 plugin <script src="./js/jquery.lazy.picture.min.js"></script>
  2. 用下面的範例插入圖片

    <picture class="lazy" data-src="./image/team_a.jpg" data-srcset="./webp/team_a.webp" data-type="image/webp" />
    
    1. 仍然要記得執行
  $('.lazy').Lazy();

CSS 背景圖的 Lazy loading

我們可以採用跟之前一樣的邏輯,先在元素上添加一個屬性當作遮罩,然後偵測元素進入 viewpoint 的事件。一旦偵測到便將遮罩刪掉,讓 background image 的屬性被瀏覽器採用。

但是我們還需要考慮到瀏覽器是否支援 webp,所以必須先檢查瀏覽器能否正確載入 webp。

  • 在 html 中幫有背景圖的元素加上屬性遮罩
  • 在 DOM 建立完後執行,如果瀏覽器支援 webp 會加上 webp 類別,否則加上 no-webp 類別。
  // 挑出有遮罩的元素
  const lazyloadBackgrounds = document.querySelectorAll('#bg-image'); 
  const lazyloadIcons = document.querySelectorAll('#icon-image');

  // 寫一個會回傳 promise 的函數,偵測載入測試用的 webp 是否成功
  const detectWebp = () => new Promise((resolve) => {
      const imgSrc = 'data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAASAAAAAAAAAAAAQU5JTQYAAAD/////AABBTk1GJgAAAAAAAAAAAAAAAAAAAGQAAABWUDhMDQAAAC8AAAAQBxAREYiI/gcA';
      const pixel = new Image();
      pixel.addEventListener('load', () => {
          const isSuccess = (pixel.width > 0) && (pixel.height > 0);
          resolve(isSuccess);
      });
      pixel.addEventListener('error', () => { resolve(false); });
      pixel.setAttribute('src', imgSrc); // 開始載入測試圖
  });

  // 儲存 promise 的結果
  const hasSupport = await detectWebp();

  // 幫元素加上 class,webp 或者 no-webp
  lazyloadBackgrounds.forEach((ele) => {
    ele.classList.add(hasSupport ? 'webp' : 'no-webp');
  })
  lazyloadIcons.forEach((ele) => {
    ele.classList.add(hasSupport ? 'webp' : 'no-webp');
  })
  • 在 scss 寫 mixin,沒有遮罩且 webp 類別存在會載入 webp,沒有遮罩且 np-webp 類別存在會載入原圖。
@mixin bg-webp($url, $type) {
    &.no-webp {
        background-image: url('./../image/'+ $url + '.' + $type);

        &#bg-image{
            background-image: none;
        }
    }

     &.webp {
        background-image: url('./../webp/'+ $url + '.webp');

        &#bg-image{
            background-image: none;
        }
    }
}

結果

這次優化的結果如下:

img

較上一階段文件全部載入的時間快了一點。

第七步 自動偵測冗碼以及打包資源

css

css 裏頭最胖的肯定是 bootstrap 了,但是因為網頁是別人設計的網頁所以不知道要怎麼挑選客製化的 bootstrap。所幸 gulp-uncss 可以自動偵測 html 裡用到的 class,幫 css 瘦身。

之後用 gulp-concat 打包,順序無妨。

js

  • js 裡最胖的是 jQuery,因為沒有要用 ajax,所以改成載壓縮過的 slim 版本
  • 使用 gulp-concat 打包,值得注意的是可以依照這樣的順序寫在 gulpfile 的 src 裡打包,雖然 js 有 hoisting 的特性,但這樣依照相依性的順序會比較安全
    1. jquery
    2. jquery-lazy
    3. jquery-lazy-picture
    4. typed
    5. slick
    6. slick-theme
    7. index
  • 使用 defer,請不要使用 async,因為裡面有一些程式碼是需要在 DOM 解析完後才執行。

結果

這次的結果優化在 domInteractive,因為所有 js 都用 defer 處理。下面是結果:

img

第八步 sprite 雪碧圖

為了可以讓圖片的 request 減少,通常會把大小一樣的圖拼成雪碧圖 (sprite),再利用 background(mask)-position的 css 把圖片切割。

下面以 footer 的四個 icon 來示範。

photoshop 製作雪碧圖

之前有將這四個 icon 統一取樣成 60px 60px 的大小,所以先開一個 240px 60px 的圖層。

img

接著選擇 檢視 -> 新增參考線配置 -> 4 欄 1 列

img

一一將圖片載入並「水平置中 垂直置中」在每一個格子裡

img

輸出 png 以後記得再轉成 webp。

設置 mask-position

  • mask-size 設置為 cover
  • 設置 mask-position
    .footer__social-media {
      display: flex;
      align-items: center;
      .footer__social-icon {
          @include size(30px);
          @include icon(#bbb);
          margin-left: 5px;
          cursor: pointer;
          &:hover {
              background: #eee;
          }
          &.icon-fb {
              -webkit-mask-position: 0 0;
              @include icon-webp('sprite-community', 'png')
          }
          &.icon-twitter {
              -webkit-mask-position: (100% / 3) 0;
              @include icon-webp('sprite-community', 'png')
          } 
          &.icon-ig {
              -webkit-mask-position: (100% / 3 * 2) 0;
              @include icon-webp('sprite-community', 'png')
          }
          &.icon-g-plus {
              -webkit-mask-position: 100% 0;
              @include icon-webp('sprite-community', 'png')
          }
      }
    }
    

background(mask)-size

這邊筆記一下為甚麼 mask-size 與 mask-position 要這樣設定,cover屬性會使得背景圖整個「不變形」、「寬高等比例」、「在必要時局部裁切」的鋪滿整個容器空間。

因為在網頁上裝社群 icon 的容器大小是 30px 30px,背景圖的大小是 240px 60px,為了要填滿整個容器,背景圖的高度重新取樣為 30px,又因為背景圖的寬高比不能變,所以寬度變為 120px。 這個情況使得背景圖的高度與容器相同、背景圖的寬度是容器的四倍

background(mask)-position

要知道決定背景圖與容器相對位置的方法,首先要知道容器本身作為個固定的座標系統,如下圖,容器的左上角為座標 (0,0),往右往下為正。

img

接著我們決定要把背景圖的左上角放在座標的哪個位置上,假設我們把背景圖放在 (0,0),就會像下圖這樣,可以順利將 sprite 圖的第一張顯示出來。

img

但是用百分比當參數的話又是怎麼回事呢? 第二張 icon 使用了 mask-position: (100% / 3) 0,百分比使用了下列的公式來轉換成座標:

(容器長[寬]度-背景長[寬]度) x 百分比 = 座標

100% / 3 就是三分之一,也就是說 mask-position 的 X 座標是 (30-120) * (1/3) = -30

把背景圖的左上角放置在 (-30,0) 的位置可以顯示第二張 icon,如下圖。

img

結果

沒有甚麼差別,可能是因為這次是用台灣的伺服器跑的,因為新加坡的有好幾百個在排隊等...

img

心得

可以再優化的地方

這次的步驟是參考這個網頁的原始製作者 yakim-shu 的優化流程的,如果說還有甚麼可以做的,我想到以下兩點:

  • css 分割,將不同的 RWD 分開成不同檔案,並以 media 屬性讓瀏覽器可以在需要時動態載入
  • cache,如果要知道 cache 的相關資訊可以參考循序漸進理解 HTTP Cache 機制

流程整理

這次主要做的事情有:

  • html/js/css 刪除冗碼
  • js/css 壓縮與打包、html 壓縮
  • 圖片壓縮
  • sprite
  • js defer
  • Lazy loading

這些流程不外乎是減低資源大小減少瀏覽器請求次數以及避免 DOM/CCSOM 的 block。其中圖片是載入速度的瓶頸所在,所以這次也在處理圖片上學到了最多。

本來以為這個挑戰可以很快完成,但是其實碰到的問題很多,一方面不熟悉工具,另一方面因為不是自己寫的網頁,所以只要動到 scss 都很容易不小心壞掉 XD

推薦大家都一定要來玩!


#crp #lazy-loading #webp #tinypng #defer #sprite







Related Posts

[ week10 ] 綜合能力測驗-攻略與解題心得

[ week10 ] 綜合能力測驗-攻略與解題心得

reverse engineer 1.1

reverse engineer 1.1

React hook form(4) - controlled component & forwardRef

React hook form(4) - controlled component & forwardRef


Comments