JavaScript DevOps Notes
PWA
Progressive Web Apps:
- Served over
HTTPS
. - Provide a manifest.
- Register a
ServiceWorker
(web cache for offline and performance). - Consists of website, web app manifest, service worker, expanded capabilities and OS integration.
Service Worker Pros
- Cache.
- Offline.
- Background.
- Custom request to minimize network.
- Notification API.
Service Worker Costs
- Need startup time.
// 20~100 ms for desktop
// 100 ms for mobile
const entry = performance.getEntriesByName(url)[0];
const swStartupTime = entry.requestStart - entry.workerStart;
- cache reads aren't always instant:
- cache hit time = read time (only this case better than
NO SW
), - cache miss time = read time + network latency,
- cache slow time = slow read time + network latency,
- SW asleep = SW boot latency + read time ( + network latency),
- NO SW = network latency.
- cache hit time = read time (only this case better than
const entry = performance.getEntriesByName(url)[0];
// no remote request means this was handled by the cache
if (entry.transferSize === 0) {
const cacheTime = entry.responseStart - entry.requestStart;
}
async function handleRequest(event) {
const cacheStart = performance.now();
const response = await caches.match(event.request);
const cacheEnd = performance.now();
}
- 服务工作者线程缓存不自动缓存任何请求, 所有缓存都必须明确指定.
- 服务工作者线程缓存没有到期失效的概念.
- 服务工作者线程缓存必须手动更新和删除.
- 缓存版本必须手动管理: 每次服务工作者线程更新, 新服务工作者线程负责提供新的缓存键以保存新缓存.
- 唯一的浏览器强制逐出策略基于服务工作者线程缓存占用的空间. 缓存超过浏览器限制时, 浏览器会基于 LRU 原则为新缓存腾出空间.
Service Worker Caching Strategy
5 caching strategy in workbox.
Stale-While-Revalidate:
// eslint-disable-next-line no-restricted-globals
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open(cacheName).then(function (cache) {
cache.match(event.request).then(function (cacheResponse) {
fetch(event.request).then(function (networkResponse) {
cache.put(event.request, networkResponse);
});
return cacheResponse || networkResponse;
});
})
);
});
Cache first, then Network:
// eslint-disable-next-line no-restricted-globals
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open(cacheName).then(function (cache) {
cache.match(event.request).then(function (cacheResponse) {
if (cacheResponse) return cacheResponse;
return fetch(event.request).then(function (networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
});
Network first, then Cache:
// eslint-disable-next-line no-restricted-globals
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
})
);
});
Cache only:
// eslint-disable-next-line no-restricted-globals
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open(cacheName).then(function (cache) {
cache.match(event.request).then(function (cacheResponse) {
return cacheResponse;
});
})
);
});
Network only:
// eslint-disable-next-line no-restricted-globals
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).then(function (networkResponse) {
return networkResponse;
})
);
});
Service Worker Usage
Register Service Worker
// Check that service workers are registered
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performance
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
Broken Images Service Worker
function isImage(fetchRequest) {
return fetchRequest.method === 'GET' && fetchRequest.destination === 'image';
}
// eslint-disable-next-line no-restricted-globals
self.addEventListener('fetch', e => {
e.respondWith(
fetch(e.request)
.then(response => {
if (response.ok) return response;
// User is online, but response was not ok
if (isImage(e.request)) {
// Get broken image placeholder from cache
return caches.match('/broken.png');
}
})
.catch(err => {
// User is probably offline
if (isImage(e.request)) {
// Get broken image placeholder from cache
return caches.match('/broken.png');
}
process(err);
})
);
});
// eslint-disable-next-line no-restricted-globals
self.addEventListener('install', e => {
// eslint-disable-next-line no-restricted-globals
self.skipWaiting();
e.waitUntil(
caches.open('precache').then(cache => {
// Add /broken.png to "precache"
cache.add('/broken.png');
})
);
});
Caches Version Service Worker
// eslint-disable-next-line no-restricted-globals
self.addEventListener('activate', function (event) {
const cacheWhitelist = ['v2'];
event.waitUntil(
caches.keys().then(function (keyList) {
return Promise.all([
keyList.map(function (key) {
return cacheWhitelist.includes(key) ? caches.delete(key) : null;
}),
// eslint-disable-next-line no-restricted-globals
self.clients.claim(),
]);
})
);
});
PWA Reference
- Service worker overview.
- Workbox library.
- Offline cookbook guide.
- PWA extensive guide.
- Network reliable web app definitive guide:
JamStack
JamStack 指的是一套用于构建现代网站的技术栈:
- JavaScript: enhancing with JavaScript.
- APIs: supercharging with services.
- Markup: pre-rendering.
Rendering Patterns
- CSR (Client Side Rendering): SPA.
- SSR (Server Side Rendering): SPA with SEO.
- SSG (Static Site Generation): SPA with pre-rendering.
- ISR (Incremental Static Regeneration): SSG + SSR.
- SSR + CSR: HomePage with SSR, dynamic with CSR.
- SSG + CSR: HomePage with SSG, dynamic with CSR.
- SSG + SSR: static with SSG, dynamic with SSR.
CSR
- CSR hit API after the page loads (LOADING indicator).
- Data is fetched on every page request.
import { TimeSection } from '@components';
export default function CSRPage() {
const [dateTime, setDateTime] = React.useState<string>();
React.useEffect(() => {
axios
.get('https://worldtimeapi.org/api/ip')
.then(res => {
setDateTime(res.data.datetime);
})
.catch(error => console.error(error));
}, []);
return (
<main>
<TimeSection dateTime={dateTime} />
</main>
);
}
SSR
Application code is written in a way that it can be executed both on the server and on the client. The browser displays the initial HTML (fetch from server), simultaneously downloads the single-page app (SPA) in the background. Once the client-side code is ready, the client takes over and the website becomes a SPA.
前后端分离是一种进步,但彻底的分离,也不尽善尽美, 比如会有首屏加载速度和 SEO 方面的困扰。 前后端分离+服务端首屏渲染看起来是个更优的方案, 它结合了前后端分离和服务端渲染两者的优点, 既做到了前后端分离,又能保证首页渲染速度,还有利于 SEO。
if (isBotAgent) {
// return pre-rendering static html to search engine crawler
// like Gatsby
} else {
// server side rendering at runtime for real interactive users
// ReactDOMServer.renderToString()
}
SSR Upside
- Smaller first meaningful paint time.
- HTML's strengths: progressive rendering.
- Browsers are incredibly good at rendering partial content.
- Search engine crawlers used to not execute scripts (or initial scripts).
- Search engine usually stop after a while (roughly 10 seconds).
- SPAs can't set meaningful HTTP status codes.
SSR Usage
Webpack configuration:
const baseConfig = require('./baseConfig');
const webConfig = {
...baseConfig,
target: 'web',
};
const nodeConfig = {
...baseConfig,
target: 'node',
output: {
...baseConfig.output,
libraryTarget: 'commonjs2',
},
externals: [require('webpack-node-externals')()],
};
module.exports = { webConfig, nodeConfig };
React server side rendering start.server.js
(compile to dist/server.js
):
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import Koa from 'koa';
import koaStatic from 'koa-static';
import { Provider } from 'react-redux';
const Routes = [
{ path: '/', component: Home, exact: true },
{
path: '/about',
component: About,
exact: true,
},
];
const getStore = () => {
return createStore(reducer, applyMiddleware(thunk));
};
const app = new Koa();
app.use(koaStatic('public'));
app.use(async ctx => {
const store = getStore();
const matchedRoutes = matchRoutes(Routes, ctx.request.path);
const loaders = [];
matchedRoutes.forEach(item => {
if (item.route.loadData) {
// item.route.loadData() 返回的是一个 promise.
loaders.push(item.route.loadData(store));
}
});
// 等待异步完成, store 已完成更新.
await Promise.all(loaders);
const content = renderToString(
<Provider store={store}>
<StaticRouter location={ctx.request.path}>
<div>{renderRoutes(Routes)}</div>
</StaticRouter>
</Provider>
);
ctx.body = `
<!DOCTYPE html>
<head>
</head>
<body>
<div id="app">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
};
</script>
<script src="/public/client.js"></script>
</body>
</html>`;
});
app.listen(3003, () => {
console.log('listen:3003');
});
React client side hydration start.client.js
(compile to public/client.js
):
- 建立 Real DOM 与 Virtual DOM 的联系:
fiber.el = node
. - 绑定事件处理器.
- 执行服务端未执行的 lifecycle hooks:
beforeMount()
/onMounted()
.
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'react-redux';
const Routes = [
{ path: '/', component: Home, exact: true },
{
path: '/about',
component: About,
exact: true,
},
];
const getStore = () => {
const defaultState = window.context ? window.context.state : {};
return createStore(reducer, defaultState, applyMiddleware(thunk));
};
const App = () => (
<Provider store={getStore()}>
<BrowserRouter>
<div>{renderRoutes(Routes)}</div>
</BrowserRouter>
</Provider>
);
ReactDOM.hydrate(<App />, document.getElementById('app'));
Isomorphic data fetch
(getStaticProps
/getServerSideProps
in Next.js
,
loader
in Remix
):
const data = await App.fetchData();
const App = <App {...data} />;
return {
html: ReactDOMServer.renderToString(App),
state: { data },
};
Next.js
SSR:
- SSR hit API before the page loads (DELAY before render, and no LOADING indicator).
- Data is fetched on every page request.
import { TimeSection } from '@components';
export default function SSRPage({ dateTime }: SSRPageProps) {
return (
<main>
<TimeSection dateTime={dateTime} />
</main>
);
}
export const getServerSideProps: GetServerSideProps = async () => {
const res = await axios.get('https://worldtimeapi.org/api/ip');
return {
props: { dateTime: res.data.datetime },
};
};
服务端返回的 HTML 与客户端渲染结果不一致时会产生
SSR Hydration Warning
,
必须重视 SSR Hydration Warning
,
要当 Error
逐个解决:
- 出于性能考虑,
hydrate
可以弥补文本内容的差异, 但并不能保证修补属性的差异, 只在development
模式下对这些不一致的问题报Warning
. - 前后端不一致时,
hydrate
时会导致页面抖动: 后端渲染的部分节点被修改, 用户会看到页面突然更改的现象, 带来不好的用户体验.
编写 SSR 组件时:
- 需要使用前后端同构的 API: 对于前端或后端独有的 API (e.g BOM, DOM, Node API), 需要进行封装与填充 (adapter/mock/polyfill).
- 注意并发与时序: 浏览器环境一般只有一个用户, 单例模式容易实现; 但 Node.js 环境可能存在多条连接, 导致全局变量相互污染.
- 部分代码只在某一端执行:
在
onCreated()
创建定时器, 在onUnmounted()
清除定时器, 由于onUnmounted()
hooks 只在客户端执行, 会造成服务端渲染时产生内存泄漏.
SSR Reference
- Universal JavaScript presentation.
- React SSR complete guide:
- SSR and hydration.
- Isomorphic router.
- Isomorphic store.
- Isomorphic CSS.
- Vue SSR guide:
- Vite.
- Router.
- Pinia.
- Next.js for isomorphic rendering.
- Server side rendering with Puppeteer.
- Web rendering guide.
SSG
- Reloading did not change anything.
- Hit API when running
npm run build
. - Data will not change because no further fetch.
import { TimeSection } from '@components';
export default function SSGPage({ dateTime }: SSGPageProps) {
return (
<main>
<TimeSection dateTime={dateTime} />
</main>
);
}
export const getStaticProps: GetStaticProps = async () => {
const res = await axios.get('https://worldtimeapi.org/api/ip');
return {
props: { dateTime: res.data.datetime },
};
};
ISR
- Based on SSG, with revalidate limit.
- Cooldown state: reloading doesn't trigger changes and pages rebuilds.
- First person that visits when cooldown state is off, is going to trigger a rebuild. That person won't be seeing changes. But, the changes will be served for the next full reload.
import { TimeSection } from '@components';
export default function ISR20Page({ dateTime }: ISR20PageProps) {
return (
<main>
<TimeSection dateTime={dateTime} />
</main>
);
}
export const getStaticProps: GetStaticProps = async () => {
const res = await axios.get('https://worldtimeapi.org/api/ip');
return {
props: { dateTime: res.data.datetime },
revalidate: 20,
};
};
Islands Architecture
- Script resources for these "islands" of interactivity (islands of dynamic components) can be delivered and hydrated independently, allowing the rest of the page to be just static HTML.
- Islands architecture combines ideas from different rendering techniques:
- Simple islands architecture implementation.
JamStack Reference
- Build your own Next.js.
- Build your own web framework.
- Modern websites building patterns.
- Modern rendering patterns.
SEO
SEO Metadata
import { Helmet } from 'react-helmet';
function App() {
const seo = {
title: 'About',
description:
'This is an awesome site that you definitely should check out.',
url: 'https://www.mydomain.com/about',
image: 'https://mydomain.com/images/home/logo.png',
};
return (
<Helmet
title={`${seo.title} | Code Mochi`}
meta={[
{
name: 'description',
property: 'og:description',
content: seo.description,
},
{ property: 'og:title', content: `${seo.title} | Code Mochi` },
{ property: 'og:url', content: seo.url },
{ property: 'og:image', content: seo.image },
{ property: 'og:image:type', content: 'image/jpeg' },
{ property: 'twitter:image:src', content: seo.image },
{ property: 'twitter:title', content: `${seo.title} | Code Mochi` },
{ property: 'twitter:description', content: seo.description },
]}
/>
);
}
SEO Best Practice
- Server side rendering (e.g Next.js).
- Pre-Rendering
- Mobile performance optimization (e.g minify resources, code splitting, CDN, lazy loading, minimize reflows).
- SEO-friendly routing and URL management.
- Google webmaster tools
<title>
and<meta>
in<head>
(with tool likereact-helmet
).- Includes a
robots.txt
file.
SEO Reference
Web Authentication
Cookie
- First request header -> without cookie.
- First response header ->
Set-Cookie: number
to client. - Client store identification number for specific site into cookies files.
- Second request header ->
Cookie: number
. (extract identification number for specific site from cookies files). - Function: create User Session Layer on top of stateless HTTP.
用户能够更改自己的 Cookie 值 (client side), 因此不可将超过权限的数据保存在 Cookie 中 (如权限信息), 防止用户越权.