照片由Mathew Schwartz在Unsplash上拍攝
這是我們為達到可靠的燈塔分數而改造主頁的故事。如果您想先了解我們是如何製作主頁的,請查看我們 2019 年的部落格文章。
我在這裡寫的許多內容都可以在《Fast Pages with React》一文中看到。
我們的主頁最初是一個 React SPA。為什麼?那是 2019 年,UI 設計師全部在 React 中建立了元件。另外,這是一種超級流暢的體驗,實際上是可以接受的。
然而,雖然有些人可能會認為 SPA 不利於 SEO,但這並不是我們想要預先渲染頁面的原因。 SEO 看起來實際上很好,但一些更簡單的爬蟲並沒有找到所有資訊,因為它隱藏在一些 JavaScript 後面。
那我們在這裡能做什麼呢?好吧,我們可以使用靜態網站生成(SSG)方法在建置時渲染所有內容。
我們不想更改頁面背後的整體內容或引擎。畢竟,DX 很棒,我們認為元件的可重複使用性比遷移到最新的 SSG 框架更重要。
建置(當時使用Parcel v1捆綁器)完成後,我們有一個建置後流程,該過程會取得產生的index.html並遍歷所有偵測到的頁面。由於我們的路由是聲明性的(我們已經從檔案系統路徑產生了路由),因此查找頁面很容易。
對於每個頁面,我們執行簡單的腳本來執行以下操作:
教 Node.js ESM 如何運作( esm
模組)
允許透過ts-node
使用 TypeScript(我們的來源正在使用 TypeScript)
加入一些全域變數,例如document
或localStorage
註冊一些額外的擴展,例如,用於將圖像解析為 Node.js 中的模組(這些圖像應解析為從捆綁器中已生成的圖像)
評估頁面 - 使用renderToString
將應用程式的內容容器替換為預渲染頁面
將修改後的應用程式的 HTML 保存在與頁面路徑相符的新檔案中
在程式碼中,其工作原理如下:
const { readFileSync, writeFileSync, mkdirSync } = require('fs');
const { basename, dirname, resolve } = require('path');
require = require('esm')(module);
require('ts-node').register({
compilerOptions: {
module: 'commonjs',
target: 'es6',
jsx: 'react',
importHelpers: true,
moduleResolution: 'node',
},
transpileOnly: true,
});
global.XMLHttpRequest = class {};
global.XDomainRequest = class {};
global.localStorage = {
getItem() {
return undefined;
},
setItem() {},
};
global.document = {
title: 'sample',
querySelector() {
return {
getAttribute() {
return '';
},
};
},
};
const React = require('react');
const { MemoryRouter } = require('react-router');
const { renderToString } = require('react-dom/server');
React.lazy = () => () => React.createElement('div', undefined, 'Loading ...');
React.Suspense = ({ children }) => React.createElement(React.Fragment, undefined, children);
React.useLayoutEffect = () => {};
function setupExtensions(files) {
['.png', '.svg', '.jpg', '.jpeg', '.mp4', '.mp3', '.woff', '.tiff', '.tif', '.xml'].forEach(extension => {
require.extensions[extension] = (module, file) => {
const parts = basename(file).split('.');
const ext = parts.pop();
const front = parts.join('.');
const ref = files.filter(m => m.startsWith(front) && m.endsWith(ext)).pop() || '';
module.exports = '/' + ref;
};
});
require.extensions['.codegen'] = (module, file) => {
const content = readFileSync(file, 'utf8');
module._compile(content, file);
const code = module.exports();
module._compile(code, file);
};
}
function renderApp(source, target, dist) {
const sourceModule = require(source);
const route = (sourceModule.meta.route || target).substring(1);
const Page = sourceModule.default;
const Layout = require('../../scripts/layout').default;
const element = React.createElement(
MemoryRouter,
undefined,
React.createElement(Layout, undefined, React.createElement(Page)),
);
return {
content: renderToString(element),
outPath: resolve(dist, route, 'index.html'),
};
}
function makePage(outPath, html, content) {
const outDir = dirname(outPath);
const file = html.replace(/<div id="app">(.*)<\/div>/, `<div id="app">${content}</div>`);
mkdirSync(outDir, {
recursive: true,
});
writeFileSync(outPath, file, 'utf8');
}
process.on('message', msg => {
const { source, target, files, html, dist } = msg;
setupExtensions(files);
setTimeout(() => {
const { content, outPath } = renderApp(source, target, dist);
makePage(outPath, html, content);
process.send({
content,
});
}, 100);
});
整個process
被用作從分叉進程呼叫給定模組。因此每個頁面都是在一個獨立的進程中產生的。
下圖說明了此過程。
到目前為止我們贏得了什麼?我們有一個已經可以執行並轉換為 SPA 的預渲染頁面。好的。但對我們來說還不夠。
我已經將這個過程描述為“是”。 Parcel v1 已經有一段時間沒有更新了,從今天的角度來看,更新速度相當慢。它也不支持一些現代概念,應該永遠退役。
作為替代品,我們選擇了Vite 。這是一個合適的替代品,因為它配備了超快速的開發伺服器和非常優化的發布版本。
此外,由於我們使用codegen進行路由檢索,我們仍然可以繼續將它用於 Vite。
畢竟,Vite允許過渡的整個配置如下所示:
import codegen from 'vite-plugin-codegen';
import { resolve } from 'path';
export default {
build: {
assetsInlineLimit: 0,
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
plugins: [codegen()],
};
為了避免 Vite 最初內聯較小資產的行為(這會導致我們的 SSG 行為出現問題),我們將assetsInlineLimit
設為 0。
從 Parcel v1 過渡到 Vite 時我們還改進的一件事是我們引入了路徑別名。透過上面的配置,我們可以寫import '@/foo/bar'
而不是明確地通過src/foo/bar
的相對路徑。這使得模組更加靈活並且更易於維護。
之前,我們使用了一些工具( esm
、 ts-node
等)來實際實作 SSG。透過新的設置,是時候減少工具的數量並將它們全部替換為esbuild
。
因此,SSG 核心模組的更新程式碼也發生了一些變化:
const { writeFile, readFile, mkdir } = require('fs/promises');
const { dirname, resolve, basename, relative } = require('path');
const { createContext, runInContext } = require('vm');
const { compile } = require('./compile');
const React = require('react');
const ReactRouter = require('react-router');
const ReactRouterDom = require('react-router-dom');
const { renderToString } = require('react-dom/server');
React.lazy = () => () => React.createElement('div', undefined, 'Loading ...');
React.Suspense = ({ children }) => React.createElement(React.Fragment, undefined, children);
React.useLayoutEffect = () => {};
React.useEffect = () => {};
async function renderApp(source, target, language, files, dist) {
const result = await compile({
dist,
files,
target,
platform: 'node',
stdin: {
contents: `
import { MemoryRouter } from "react-router";
import Page, { meta } from "./${basename(source)}";
import Layout from "${relative(dirname(source), resolve(__dirname, '../../layouts/default'))}";
const page = (
<MemoryRouter>
<Layout language="${language}">
<Page />
</Layout>
</MemoryRouter>
);
export { page, meta };
`,
sourcefile: resolve(dirname(source), 'temp-page.jsx'),
resolveDir: dirname(source),
loader: 'jsx',
},
});
const module = {
exports: {},
};
const ctx = createContext({
exports: module.exports,
require(name) {
switch (name) {
case 'react':
return React;
case 'react-router':
return ReactRouter;
case 'react-router-dom':
return ReactRouterDom;
default:
console.error('Cannot require', name);
return undefined;
}
},
setTimeout() {},
setInterval() {},
React,
module,
XMLHttpRequest: class {},
XDomainRequest: class {},
localStorage: {
getItem() {
return undefined;
},
setItem() {},
},
document: {
title: 'sample',
querySelector() {
return {
getAttribute() {
return '';
},
};
},
},
});
const code = result.outputFiles.find((m) => m.path.endsWith('.js')).text;
runInContext(code, ctx);
const { page, meta } = module.exports;
return {
content: renderToString(page),
meta,
};
}
async function makeFile(outPath, content) {
const outDir = dirname(outPath);
await mkdir(outDir, {
recursive: true,
});
await writeFile(outPath, content, 'utf8');
}
function makePage(outPath, meta, html, content) {
return makeFile(outPath, html.replace(/<div id="app">.*?<\/div>/s, `<div id="app">${content}</div>`);
}
process.on('message', (msg) => {
const { source, target, files, language, html, dist } = msg;
setTimeout(async () => {
const { content, replacements, meta } = await renderApp(source, target, language, files, dist, html);
const route = (meta.route || target).substring(1);
const outPath = resolve(dist, route, 'index.html');
await makePage(outPath, meta, html, content);
process.send({
done: true,
outPath,
replacements,
});
}, 100);
});
雖然整體流程保持不變,但我們現在使用 CommonJS 模組系統將帶有一些導入(例如MemoryRouter
)的頁面轉換為純 JavaScript 腳本。因此,使用 Node.js vm
模組進行評估不再需要esm
或ts-node
。一切都已由 esbuild 處理完畢。
在上面的程式碼中,實際的 esbuild 用法隱藏在compile
函數中,所以讓我們看看如何設定它:
const { build } = require('esbuild');
const { codegenPlugin } = require('esbuild-codegen-plugin');
const { resolve, basename } = require('path');
function compile(opts) {
const { dist, target, stdin, files, platform, entryPoints } = opts;
const isBrowser = platform === 'browser';
return build({
stdin,
entryPoints,
outdir: resolve(dist, target.substring(1)),
write: false,
bundle: true,
splitting: isBrowser,
minify: isBrowser,
format: !isBrowser ? 'cjs' : 'esm',
platform,
loader: {
'.jpg': 'file',
'.png': 'file',
'.svg': 'file',
'.avif': 'file',
'.webp': 'file',
},
alias: {
'@': resolve(__dirname, '../..'),
},
external: ['react', 'react-router', 'react-router-dom', 'react-dom', 'react-dom/client'],
plugins: [
codegenPlugin(),
{
name: 'dynamic-assets',
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
const name = basename(args.path);
const idx = name.lastIndexOf('.');
const front = name.substring(0, idx);
const ext = name.substring(idx);
const prefix = front + '-';
const file = files.find((m) => m.startsWith(prefix) && m.endsWith(ext));
if (file) {
return {
path: file,
namespace: 'dynamic-asset',
};
}
return undefined;
});
build.onLoad({ namespace: 'dynamic-asset', filter: /.*/ }, (args) => {
const path = `/assets/${args.path}`;
return {
contents: `export default ${JSON.stringify(path)};`,
loader: 'js',
};
});
},
},
],
});
}
exports.compile = compile;
非常簡單。需要注意的幾件事是:
同樣, codegen
的使用非常出色,因為每個捆綁器實際上都有一個插件。所以我們可以只使用 esbuild 的 codegen 插件,我們就在這裡介紹。
上面定義的動態資源外掛程式會在檔案中尋找名稱和副檔名是否合適;如果確實如此,它將使用 Vite 中已經產生的一個。
我們不僅可以為 Node.js(SSG 部分)重複使用compile
函數,還可以編譯一些在瀏覽器中執行的 JS 函數。
尤其是最後一點至關重要。現在,這項設定確實將動態部分(SPA)轉變為完全靜態部分。但我們仍然有一些互動部分......所以完全水化這個東西 - 正如我們之前所做的 - 可能還不夠好。
但即使採用了這種更現代的設置,有一點仍然是 2019 年的:整體性能。
2019 年,我們的 Lighthouse 得分仍然很高,但現在充其量只是平庸。燈塔變得更加咄咄逼人——尤其是在我們這裡的水合場景方面。
分數總結如下:
|類別 |桌面|手機 |
| -------- | -------- | ------ |
|分數 | 70 | 70 50 | 50
|最佳實踐 | 81 | 81 79 | 79
|第一個內容豐富的繪畫 | 0.4 秒 | 1.7 秒 |
|最大的內容繪畫| 1.6 秒 | 8.4秒|
|總阻塞時間| 0 毫秒 | 20 毫秒 |
|累積佈局偏移| 1.828 | 1.828 1.899 | 1.899
|速度指數| 0.7 秒 | 2.1秒|
有一些發現看起來相當可選,但仍然可以相當快速地實施(或根本不實施):
簡單:沒有 CSP 標頭(因為這只是作為靜態內容,我們可以將其作為meta
標記輕鬆嵌入 HTML 中)
乏味:在行動裝置上,某些影像以較低的解析度提供(大概進入具有不同source
元素的picture
標籤以傳回「正確」的解析度)
不可能:使用了已棄用的 API( unload
處理程序;改為使用pagehide
事件 - 實際上我們不使用這個:問題來自於/從 Chrome 擴充功能注入)
當然,問題是為什麼分數如此低,以及我們能做些什麼。值得注意的是,我們有一個 CLS(累積佈局偏移)和一個相當大的 LCP(最大內容繪製)。
首先要做的是減少 JS 引擎要做的工作量。為此,我們需要重新考慮我們的補水策略。 React SPA 的標準做法是讓所有東西都水合。但這會帶來大量開銷並且啟動速度相當緩慢。
更好的方法是在這裡包含島嶼建築風格。我們不是一開始就對所有東西進行水合,而是只對部分進行水合,並且僅在需要時(例如,當它們變得可見時)對這些部分進行水合。
我們怎樣才能做到這一點?施展魔法的時間到了。
讓我們考慮以下程式碼:
const Testimonials: React.FC = () => (
<>
<div className="container">
<h1>Testimonials</h1>
</div>
<div className="quote-carousel">
<TestimonialsSlides />
</div>
</>
);
這是主頁的推薦部分。一切都應該是靜態的 - 除了TestimonialsSlides
。這是一個包含一些內容的輪播。一切都在 React 中定義。
如果我們可以告訴系統要為它補水怎麼辦?假設我們將程式碼重寫為如下所示:
const Testimonials: React.FC = () => (
<>
<div className="container">
<h1>Testimonials</h1>
</div>
<div className="quote-carousel" data-hydrate={TestimonialsSlides}>
<TestimonialsSlides />
</div>
</>
);
我們唯一改變的是我們加入了data-hydrate
屬性。當然,這應該是一個字串 - 但(令人驚訝)我們無論如何都不會使用該屬性。我們實際上會在建置時更改屬性值。
我們心目中的架構的工作原理如下:
SSG 核心模組中的建置時變更是對替換的補充:
const { createHash } = require('crypto');
async function getUniqueName(path) {
const fn = basename(path);
const name = fn.substring(0, fn.lastIndexOf('.'));
const content = await readFile(path);
const value = createHash('sha1').update(content);
const hash = value.digest('hex').substring(0, 6);
return `${name}.${hash}`;
}
const matcher = /"data-(hydrate|render|load)":\s+(\w+)[,\s]/g;
const replacements = [];
while (true) {
const match = matcher.exec(code);
if (!match) {
break;
}
const lookup = match[0];
const kind = match[1];
const componentName = match[2];
const pos = code.indexOf(`var ${componentName}`);
const idx = code.lastIndexOf('\n// ', pos) + 4;
const src = code.substring(idx, code.indexOf('\n', idx));
const entry = resolve(__dirname, '../../..', src);
const name = await getUniqueName(entry);
replacements.push({
lookup,
entry,
name,
value: `"data-${kind}": "/assets/${name}.js",`,
});
}
for (const { lookup, value } of replacements) {
code = code.replace(lookup, value);
}
好的!透過此更改,我們可以在建置過程中替換屬性。我們也確定了稍後在一個聯合建造過程中使用它們的替代品(以便它們產生公共區塊):
const { writeFile } = require('fs/promises');
const { basename, resolve } = require('path');
const { compile } = require('./compile');
process.on('message', (msg) => {
const { replacements, dist, files, target } = msg;
setTimeout(async () => {
const result = await compile({
dist,
files,
target,
platform: 'browser',
entryPoints: replacements.map((m) => ({ in: m.entry, out: m.name })),
});
for (const file of result.outputFiles) {
const name = basename(file.path);
const content = file.text;
await writeFile(resolve(dist, target.substring(1), name), content, 'utf8');
}
process.send({
done: true,
});
}, 100);
});
唯一缺少的是 SPA 腳本的替換。替代品應該能夠使用data-hydrate
屬性,該屬性告訴網站使用特定檔案來水合容器:
function integrate() {
function getProps(element: Element) {
try {
return JSON.parse(element.getAttribute('data-props'));
} catch {
return {};
}
}
function load(fn: string) {
const react = import('react');
const reactDom = import('react-dom/client');
const mod = import(fn);
return Promise.all([react, reactDom, mod]);
}
document.querySelectorAll('*[data-hydrate]').forEach((element) => {
const fn = element.getAttribute('data-hydrate');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
observer.disconnect();
load(fn).then(([{ createElement }, { hydrateRoot }, m]) => {
requestAnimationFrame(() => hydrateRoot(element, createElement(m.default, getProps(element))));
});
}
});
});
observer.observe(element);
});
document.querySelectorAll('*[data-load]').forEach((element) => {
const fn = element.getAttribute('data-load');
load(fn).then(([{ createElement }, { hydrateRoot }, m]) => {
requestIdleCallback(() => hydrateRoot(element, createElement(m.default, getProps(element))));
});
});
document.querySelectorAll('*[data-render]').forEach((element) => {
const fn = element.getAttribute('data-render');
load(fn).then(([{ createElement }, { createRoot }, m]) => {
createRoot(element).render(createElement(m.default, getProps(element)));
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', integrate);
} else {
integrate();
}
差不多就是這樣了!最棒的是這個腳本非常小,可以內聯。因為我們也知道何時/是否需要替換,所以我們僅在需要時(即,當我們可能水合時)內聯腳本。
最後,我們外部化了react
(和其他),但我們沒有指定替代品。當 esbuild 產生 esm 檔案時,這些外部檔案將作為標準匯入放置,例如:
import * as React from 'react';
如果在瀏覽器中評估這樣的事情,我們就會遇到問題。解決方案是在我們的頁面中引入一個importmap:
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]/client"
}
}
</script>
請注意,導入映射無論如何都是延遲載入的。因此,它開箱即可發揮最佳性能。
一切就緒後,我們可以再次查看燈塔得分。
分數總結如下:
|類別 |桌面|手機 |
| -------- | -------- | ------ |
|分數 | 78 | 78 65 | 65
|最佳實踐 | 81 | 81 79 | 79
|第一個內容豐富的繪畫 | 0.6 秒 | 4.3秒|
|最大的內容繪畫| 1.3 秒 | 9.4秒|
|總阻塞時間| 0 毫秒 | 10 毫秒 |
|累積佈局偏移| 0 | 0 |
|速度指數| 0.6秒| 4.3秒|
雖然分數已經好得多,但 LCP 和整體速度指數實際上變得更糟。分數較好的原因最明顯的是 CLS 現在為 0,這很好。
那我們到底錯在哪裡呢?
雖然 SPA 轉換損害了我們的分數,但它並沒有像嚴重水合的成分那樣造成損害。這些元件之一是主輪播,它會立即顯示。
事實證明,輪播元件只是為 SPA 設計的。它根據給定的內容動態計算投影片的寬度和位置。下圖展示了其工作原理:
簡而言之,我們有一個獲取元件尺寸的容器。在容器內部,我們使用一個內容元素,其寬度乘以投影片數 + 2。這樣,我們就可以擁有真正的輪播體驗了。從最後一張投影片開始,您可以繼續向右滑動以到達第一張投影片,反之亦然。本質上,這允許您繼續朝一個方向滾動。
我們可以做些什麼來改進這裡的程式碼?
我們傳回一個預先計算的style
物件,而不是依賴執行階段來設定style
屬性。這樣,我們僅在滾動操作開始時更改動態屬性。
最初,該程式碼具有以下功能:
const updateOffset = () => {
const c = container.current?.parentElement;
if (c) {
const o = offset.current;
let transform = 'translateX(0)';
let transition = 'none';
if (state.desired !== state.active) {
const dist = Math.abs(state.active - state.desired);
const pref = Math.sign(o || 0);
const dir = (dist > length / 2 ? 1 : -1) * Math.sign(state.desired - state.active);
const shift = (100 * (pref || dir)) / (length + 2);
transition = smooth;
transform = `translateX(${shift}%)`;
} else if (!isNaN(o)) {
if (o !== 0) {
transform = `translateX(${o}px)`;
} else {
transition = elastic;
}
}
c.style.transform = transform;
c.style.transition = transition;
c.style.left = `-${(state.active + 1) * 100}%`;
}
};
現在我們轉向:
const updateOffset = () => {
const c = container.current;
if (c) {
const o = offset.current;
let transform = 'translateX(0)';
let transition = 'none';
if (state.desired !== state.active) {
const shift = getShift(o, state.active, state.desired, length + 2);
transition = smooth;
transform = `translateX(${shift}%)`;
} else if (!isNaN(o)) {
if (o !== 0) {
transform = `translateX(${o}px)`;
} else {
transition = elastic;
}
}
c.style.transform = transform;
c.style.transition = transition;
}
};
新的getShift
函數也有很大改進:
function getShift(o: number, active: number, desired: number, total: number) {
if (!o) {
const end = total - 3;
if (!desired && active === end) {
o = -1;
} else if (desired === end && !active) {
o = 1;
}
}
if (o) {
const pref = Math.sign(o);
return (100 * pref) / total;
} else {
const diff = active - desired;
return (100 * diff) / total;
}
}
這會帶來更好的使用者體驗,同時也改善了 SSG / SSR 的故事。
除了輪播元件上的工作外,我們還優化了圖像,並花了一些時間進行內容清理。沒什麼戲劇性的——只是到處都有一些小勝利。
分數總結如下:
|類別 |桌面|手機 |
| -------- | -------- | ------ |
|分數 | 99 | 99 87 | 87
|最佳實踐 | 78 | 78 75 | 75
|第一個內容豐富的繪畫 | 0.6 秒 | 2.6秒|
|最大的內容繪畫| 0.8 秒 | 3.4秒|
|總阻塞時間| 0 毫秒 | 10 毫秒 |
|累積佈局偏移| 0.009 | 0.009 0 |
|速度指數| 0.6秒| 2.6秒|
就是這樣!令人驚訝的是,單一元件中的這種「小」變化可以產生如此巨大的結果。但最終一切都取決於細節。
雖然現在每個指標看起來都不錯,但最佳實踐變得更糟了。為什麼?因為我們在主頁中加入了一個 YouTube 嵌入 ( iframe
) 形式的小影片。由於 YouTube 帶來了相當多的 cookie 和其他東西,Lighthouse 對第三方內容不太滿意。所以我們實際上可以忽略這一點。
正如想要進入 90 年代的評級一樣,這當然是成功的並且符合預期。
總的來說,我們保持了程式碼庫的穩定,只是對整個原始碼進行了一些改造。這足以達到更高水準的 DX 和效能。
下一步,我們將對一些內容進行現代化改造,並透過劫持內部連結重新引入 SPA 過渡;在頁面轉換期間載入 HTML 片段。我們也可能用整合包替換導入映射,並使用相容模式下的 Preact 作為替代品。