š÷eNuxt 進階應用:HTML å£“ç¸®æŠ€å·§å…¨è§£æž | Alex Liu

Nuxt 進階應用:HTML 壓縮技巧全解æž

• 11 min read

在網é é–‹ç™¼çš„éŽç¨‹ä¸­ï¼Œå¦‚何在讓網站的效能有更多的æå‡ä¸€ç›´éƒ½æ˜¯å€‹éžå¸¸æ·±çš„å­¸å•。在這篇文章中,我將分享如何在 Nuxt 中使用 html-minifier-terser è™•ç† HTML 壓縮,盡å¯èƒ½çš„æå‡ç¶²é åŠ è¼‰é€Ÿåº¦ã€‚å¦‚æžœä½ ä¹Ÿæ­£åœ¨ä½¿ç”¨ Nuxt 架設網站,相信這篇內容會讓你很有收穫,那就讓我們一起看下去å§ï¼

å‰è¨€

é€™ç¯‡æœƒä»¥æˆ‘å¦‚ä½•å£“ç¸®é€™å€‹éƒ¨è½æ ¼çš„éœæ…‹ HTML 為例。如果是熟悉 Vue çš„æœ‹å‹æƒ³è¦è‡ªæž¶éƒ¨è½æ ¼ï¼Œæˆ‘會首推é¸ç”¨ Nuxt Content,Nuxt Content 簡單易用好上手,輕輕鬆鬆就å¯ä»¥æ‰“造出具有個人風格的網站。

這篇內容牽涉到 Nuxt 比較進階的使用方å¼ï¼Œåƒæ˜¯ Nuxt Lifecycle Hooks ä»¥åŠ Nitro Server Plugins,如果ä¸ç†Ÿæ‚‰çš„的還是å¯ä»¥å…ˆçœ‹å®Œä¸¦ç…§è‘—嘗試åšä¸€é,最後都會附上文件跟åƒè€ƒé€£çµã€‚

為什麼è¦å£“縮 HTML

事實上 Nuxt 已經是一個éžå¸¸é«˜æ•ˆçš„æ¡†æž¶äº†ï¼Œä½†ä»”ç´°çœ‹éŽ Nuxt 生æˆçš„ HTML 檔案後還是發ç¾äº†ä¸€äº›å¯ä»¥æ”¹é€²çš„地方。下圖 HTML 是未壓縮å‰çš„æ¨£å­ã€‚

ç‚ºäº†æœ‰å€‹æ˜Žç¢ºçš„æ¯”è¼ƒåŸºæº–ï¼Œæˆ‘å€‘ä»¥ç›®å‰æœ€é•·çš„æ–‡ç« ï¼šã€Œæ·±å…¥æ·ºå‡º axios(二):XMLHttpRequestã€CancelTokenã€ç‚ºä¾‹ã€‚

Nuxt Content 壓縮å‰çš„ HTML - by Alex Liu

ä¸é›£çœ‹å‡ºä¸€äº›å¯ä»¥æ”¹å–„的方å‘ï¼Œåƒæ˜¯ï¼š

  • 移除ä¸å¿…è¦çš„「空白ã€ä»¥åŠã€Œæ›è¡Œã€ã€‚
  • 壓縮 <style> 標籤內的 CSS。
  • 壓縮 <script> 標籤內的 JavaScript。

ç›®å‰çš„ HTML 檔案大å°ç‚ºï¼š180.4 Kib

éŽåŽ»çš„ Nuxt 如何壓縮 HTML

根據 Nuxt 2 文件中æåˆ°çš„設定,å¯ä»¥é€éŽåœ¨ nuxt.config.js 中加入設定來壓縮 HTML,但在 Nuxt 3 的文件跟原始碼裡é¢ä¸¦æ²’有找到相關的設定,看起來我們必須自己動手處ç†ã€‚

但我們å¯ä»¥åƒè€ƒ Nuxt 2 使用 html-minifier-terser(Nuxt 2 實際上是用 html-minifier) é€™å€‹å·¥å…·å¹«åŠ©æˆ‘å€‘è™•ç† HTML 壓縮。接下來就來看看如何將 html-minifier-terser æ•´åˆé€² Nuxt 進行 HTML 壓縮。

Nuxt 實作 HTML 壓縮

首先我們需è¦å…ˆå®‰è£ html-minifier-terser:

pnpm install html-minifier-terser -D

é¸å®šäº†ä½¿ç”¨çš„å£“ç¸®å·¥å…·ï¼ŒæŽ¥ä¸‹ä¾†å°±è¦æƒ³è‘—如何進行壓縮了,這時候有兩個方å‘å¯ä»¥é¸æ“‡ã€‚

寫一個 node script 來處ç†ã€‚

我們å¯ä»¥é€éŽ fs è®€å– dist 資料夾下的 HTML 檔案,å†é€éŽ html-minifier-terser 處ç†å¾Œå†å›žå¯«å›žåŽ»ã€‚é€™æ¨£çš„æ–¹å¼åœ¨æ¯æ¬¡åŸ·è¡Œ pnpm generate 後都è¦å†åŸ·è¡Œä¸€æ¬¡ node script 去讀寫檔案,整個æµç¨‹è®Šå¾—很麻煩,並且他åªé©ç”¨æ–¼éœæ…‹æª”案。

é€éŽ Nuxt æä¾›çš„ Lifecycle Hooks 來處ç†ã€‚

我們或許å¯ä»¥å˜—試找到在 Server Render 後生æˆéœæ…‹æª”案å‰çš„ hook å°‡ HTML 壓縮變å°ï¼Œé€™æ¨£èªªä¸å®šå¯ä»¥åœ¨ pnpm generate çš„éŽç¨‹ç›´æŽ¥è™•ç†å¥½ HTML 壓縮,ä¸ç”¨å†å¦å¤–寫一個 node script 來處ç†ï¼ŒåŒæ™‚這個方法也é©ç”¨æ–¼ Server Side Render。

在 Nuxt 的文件當中我們å¯ä»¥çœ‹åˆ° render:html ä»¥åŠ render:response 兩個 Nitro Engine çš„ hook,這兩個 hook,他們的說明如下。

  • render:html - 在建立 HTML 之å‰å‘¼å«ã€‚
  • render:response - åœ¨æ¯æ¬¡å›žæ‡‰è¢«é€å‡ºå‰è§¸ç™¼ã€‚

這兩個 hooks 都是 Nitro Engine çš„ hook,所以我們å¯ä»¥åœ¨ /server/plugins è£¡é¢æ–°å¢žä¸€å€‹ html-minifier.ts ä¾†è™•ç† HTML 壓縮。

// server/plugins/html-minifier.ts

export default defineNitroPlugin((nitroApp) => {
  // ...
});

我們使用 render:html 這個 hook,實作後的程å¼ç¢¼ã€‚

import { minify } from 'html-minifier-terser';

export default defineNitroPlugin(nitroApp => {
  nitroApp.hooks.hook('render:html', async html => {
    const [head, bodyPrepend, body, bodyAppend] = await Promise.all([
      handleMinify(html.head),
      handleMinify(html.bodyPrepend),
      handleMinify(html.body),
      handleMinify(html.bodyAppend),
    ]);

    html.head = head;
    html.bodyPrepend = bodyPrepend;
    html.body = body;
    html.bodyAppend = bodyAppend;
  });
});

function handleMinify(contents: string[]) {
  return Promise.all(
    contents.map(async content => {
      const newContent = await minify(content, {
        removeComments: true,
        collapseWhitespace: true,
        minifyCSS: true,
        minifyJS: true,
      });

      return newContent;
    })
  );
}

render:html 這個 hook 我們會拿到一包å為 html çš„ render context,其中包å«äº† headã€bodyPrependã€body ä»¥åŠ bodyAppend 四個陣列,分別代表了 <head>ã€<body>ã€<body> çµæŸæ¨™ç±¤å‰ä»¥åŠ </body> çµæŸæ¨™ç±¤å¾Œçš„內容。

下é¢é€™å€‹æ˜¯ html 的型別定義:

interface NuxtRenderHTMLContext {
  htmlAttrs: string[]
  head: string[]
  bodyAttrs: string[]
  bodyPrepend: string[]
  body: string[]
  bodyAppend: string[]
}

我們將 headã€bodyPrependã€body ä»¥åŠ bodyAppend 這四個陣列的內容é€ä¸€é€²è¡Œå£“縮並寫回 htmlï¼Œæœ€å¾Œçš„åˆ°çš„éœæ…‹æª”案長得åƒé€™æ¨£ã€‚

Nuxt Content 壓縮後的 HTML - by Alex Liu

HTML 明顯變得更緊密了ï¼å°‘了很多æ›è¡Œï¼ŒCSS 也被壓æˆäº†ä¸€è¡Œï¼Œé€£ UnoCSS 上的註解也全部都消失了,這時候的。

ç¶“éŽé€™æ¬¡å£“縮後的 HTML 檔案大å°ç‚ºï¼š167.69 Kib(壓縮率 ≈ 7.04%)

但是還是有一些些å•題,例如:

  • <!DOCTYPE html> è·Ÿ <html> ä»¥åŠ <head> 之間æ›è¡Œé‚„在。
  • <html lang="zh-TW"> è·Ÿ <body > 都多了一個空白。
  • 最大的å•題:水åˆå¤±æ•— Hydration completed but contains mismatches.

æ°´åˆå¤±æ•—是因為我們壓縮了 <body> 裡é¢çš„內容,è¦è§£æ±ºé€™å€‹å•題,我們åªéœ€è¦é¿å…去壓縮 html.body å³å¯ã€‚

import { minify } from 'html-minifier-terser';

export default defineNitroPlugin(nitroApp => {
  nitroApp.hooks.hook('render:html', async html => {
-   const [head, bodyPrepend, body, bodyAppend] = await Promise.all([
+   const [head, bodyPrepend, bodyAppend] = await Promise.all([
      handleMinify(html.head),
      handleMinify(html.bodyPrepend),
-     handleMinify(html.body),
      handleMinify(html.bodyAppend),
    ]);

    html.head = head;
    html.bodyPrepend = bodyPrepend;
-   html.body = body;
    html.bodyAppend = bodyAppend;
  });
});

但è¦è§£æ±ºå‰å…¶ä»–兩個å•題就沒那麼容易了。為什麼 <!DOCTYPE html> è·Ÿ <html> ä»¥åŠ <head> 之間有個æ›è¡Œï¼Ÿç‚ºä»€éº¼ <html lang="zh-TW"> è·Ÿ <body > 都多了一個空白?

我們å¯ä»¥é€éŽ Nuxt çš„ renderer.ts 來找到答案,下列是在 renderer.ts 中使用的 renderHTMLDocument function。

function joinTags (tags: string[]) {
  return tags.join('')
}

function joinAttrs (chunks: string[]) {
  return chunks.join(' ')
}

function renderHTMLDocument (rendered: NuxtRenderContext) {
  return `<!DOCTYPE html>
<html ${joinAttrs(rendered.html.htmlAttrs)}>
<head>${joinTags(rendered.html.head)}</head>
<body ${joinAttrs(rendered.html.bodyAttrs)}>${joinTags(rendered.html.bodyPreprend)}${joinTags(rendered.html.body)}${joinTags(rendered.html.bodyAppend)}</body>
</html>`
}

舉例來說這是一筆 html 的資料(來æºï¼šNuxt 3 Playground)

{
  html: {
    island: false,
    htmlAttrs: [''],
    head: [
      '<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<link rel="preload" as="fetch" crossorigin="anonymous" href="/_payload.json">\n<link rel="modulepreload" as="script" crossorigin href="/_nuxt/entry.5uDhBsHa.js">\n<link rel="prefetch" as="script" crossorigin href="/_nuxt/error-404.FuMq0F0V.js">\n<link rel="prefetch" as="script" crossorigin href="/_nuxt/vue.f36acd1f.WDjT49Gm.js">\n<link rel="prefetch" as="script" crossorigin href="/_nuxt/error-500.SoaWxXGJ.js">\n<script type="module" src="/_nuxt/entry.5uDhBsHa.js" crossorigin></script>'
      ],
    bodyAttrs: [''],
    bodyPreprend: [],
    body: [
      '<div id="__nuxt"><div> Nuxt 3 Playground </div></div>'
    ],
    bodyAppend: [
      '<script type="application/json" id="__NUXT_DATA__" data-ssr="true" data-src="/_payload.json">[{"state":1,"once":3,"_errors":5,"serverRendered":7,"path":8,"prerenderedAt":9},["Reactive",2],{},["Reactive",4],["Set"],["Reactive",6],{},true,"/",1703483989454]</script>\n<script>window.__NUXT__={};window.__NUXT__.config={public:{},app:{baseURL:"/",buildAssetsDir:"/_nuxt/",cdnURL:""}}</script>'
    ]
  }
}

由此我們å¯ä»¥å¾—出上é¢å…©å€‹å•題的答案:

  • <!DOCTYPE html> è·Ÿ <html> ä»¥åŠ <head> 之間æ›è¡Œé‚„åœ¨ï¼Œæ˜¯å› ç‚ºå­—ä¸²æ¨¡æ¿æœ¬èº«å°±å«æœ‰æ›è¡Œã€‚
  • <html lang="zh-TW"> è·Ÿ <body > 都多了一個空白,是因為 html.bodyAttrs é™£åˆ—è£¡é¢æœ‰ä¸€å€‹ç©ºå­—串,所以會多一個空白。

想è¦ç¶­æŒç¾ç‹€ä¸¦ä¸”åŒæ™‚解決這個å•題å¯èƒ½å°±è¦è¦åˆ° Nuxt 底層的原始碼去修改了。那除此之外我們還有沒有其他方法å¯ä»¥è§£æ±ºæŽ‰é€™å…©å€‹å•題呢?

使用 render:response 這個 hooks 試試看å§ï¼ä¸å›‰å”†å…ˆçœ‹çµæžœï¼š

import { minify } from 'html-minifier-terser';

export default defineNitroPlugin(nitroApp => {
  nitroApp.hooks.hook('render:response', async response => {
    if (typeof response.body === 'string') {
      response.body = response.body.replace('<body >', '<body>');

      const regex = /<body>([\s\S]*?)<\/body>/;
      const match = regex.exec(response.body);

      if (match) {
        const body = match[0];
        const html = await minify(response.body.replace(body, ''), {
          collapseWhitespace: true,
          minifyCSS: true,
          minifyJS: true,
        });

        response.body = html.replace(/(<\/head>)/, '' + body);
      }
    }
  });
});

è¦ç†è§£é€™è£¡åœ¨åšä»€éº¼ï¼Œé¦–å…ˆè¦çŸ¥é“ response è£¡é¢æœ‰å“ªäº›è³‡æ–™ï¼Œä¸‹åˆ—是 response 的型別定義:

interface NuxtRenderResponse {
  body: string,
  statusCode: number,
  statusMessage?: string,
  headers: Record<string, string>
}

其中的 body 就是執行 renderHTMLDocument(htmlContext) çš„çµæžœã€‚

// Construct HTML response
const response = {
  // 👇 這裡是最終的 html 字串
  body: renderHTMLDocument(htmlContext),
  statusCode: getResponseStatus(event),
  statusMessage: getResponseStatusText(event),
  headers: {
    'content-type': 'text/html;charset=utf-8',
    'x-powered-by': 'Nuxt'
  }
} satisfies RenderResponse

所以我們å¯ä»¥æ‹¿è‘—這個 body 給 html-minifier-terser 壓縮。但直接壓縮 body 會é‡åˆ°å‰é¢æåˆ°çš„æ°´åˆå¤±æ•—的錯誤,因為我們也把 <body> 裡é¢çš„內容也壓縮掉了。解決方å¯ä»¥å…ˆç”¨æ­£å‰‡ï¼Œ/<body>([\s\S]*?)<\/body>/,把 <body> 裡é¢çš„原始內容題å–å‡ºä¾†ï¼Œå°‡å‰©ä¸‹çš„çµæžœå£“ç¸®å®Œå¾Œå†æŠŠ <body> 的內容組åˆå›žåŽ»ã€‚

æœ€çµ‚å¾—åˆ°çš„çµæžœå°±æœƒåƒé€™æ¨£ï¼š

Nuxt Content 最終壓縮後的 HTML - by Alex Liu

  • 除了 <body> 以外得æ›è¡Œéƒ½è¢«ç§»é™¤äº†ã€‚
  • <head> å…§çš„ CSS è·Ÿ JavaScript 都被壓縮æˆä¸€è¡Œäº†ã€‚
  • <html lang="zh-TW"> è·Ÿ <body > 多了一個空白的部分也被移除了。

æœ€çµ‚å£“ç¸®çµæžœåŸ·è¡Œä¸Šä¹Ÿæ²’有é‡åˆ°ä»€éº¼å•題,而壓縮後的檔案大å°ç‚ºï¼š174.96 Kib(壓縮率 ≈ 3.02%)

總çµ

在這篇文章中,我們使用 html-minifier-terser é€™å€‹å·¥å…·å¹«åŠ©æˆ‘å€‘è™•ç† HTML 壓縮,並且é€éŽ Nuxt Lifecycle Hooks 來處ç†ï¼Œæœ€å¾Œå¾—到了一個壓縮率約 3% çš„æˆæžœã€‚

å—陿–¼æˆ‘們ä¸èƒ½åŽ»ä¿®æ”¹åˆ° <body> 裡é¢çš„å…§å®¹ï¼Œæ‰€ä»¥æœ€çµ‚çš„å£“ç¸®çŽ‡çœ‹èµ·ä¾†ä¸¦ä¸æ˜¯é‚£éº¼çš„驚人,但也算是有一點點進步拉ï¼è€Œé€²æ­¥æœ€å¤§çš„部分就屬 <head> å…§çš„ inline styles 了。

最後來觀察打包的時間差異。無壓縮版本的é é¢ç”Ÿæˆéœæ…‹æª”案花了約 3046ms 但是加入壓縮æµç¨‹ç”Ÿæˆéœæ…‹æª”案增加到了 3250ms,多了 204ms çš„æ™‚é–“ã€‚ä»¥éœæ…‹ç¶²é ä¾†èªªé€™ä¸€é»žæˆæœ¬æˆ–許還算值得,畢竟這個時間åªåœ¨ç”Ÿæˆéœæ…‹æª”案的時候,之後所有載入é é¢çš„讀者都å¯ä»¥äº«å—到這個效能æå‡ã€‚

但我自己手上 Server Side Render çš„å°ˆæ¡ˆç›®å‰æ²’æœ‰é¸æ“‡å°Žå…¥é€™å€‹åŠŸèƒ½ï¼Œå°æ–¼ Server Side Render çš„é é¢æ¯æ¬¡è«‹æ±‚都會多花比這 204ms 更多或少一點的時間去壓縮 HTML,節çœçš„傳輸時間å¯èƒ½é‚„ä¸å¤ æŠµæ¶ˆé€™å€‹æˆæœ¬ï¼Œæ‰€ä»¥é‚„是è¦è¦–情æ³çœ‹å°ˆæ¡ˆä¾†æ±ºå®šå£“ç¸®èˆ‡å¦æˆ–æ˜¯é¸æ“‡ä½¿ç”¨ä¸åŒçš„壓縮設定。

在撰寫這篇文章æœé›†è³‡æ–™çš„åŒæ™‚也發ç¾äº†ä¸€å€‹ç”± Rust 編寫的壓縮工具 minify-html,按照他的 performance 圖表來看很有機會å–得更少的壓縮時間,如果有機會的話我會å†è©¦è©¦çœ‹é€™å€‹å·¥å…·ã€‚

åƒè€ƒè³‡æ–™

è«‹æˆ‘å–æ¯å’–å•¡

如果這裡的內容有幫助到你的話,一æ¯å’–å•¡å°±æ˜¯å°æˆ‘最大的鼓勵。

è«‹æˆ‘å–æ¯å’–å•¡
ÿÿÿÿ