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 中 (如权限信息), 防止用户越权.
HTTP Basic Authentication
HTTP basic authentication is 401 authentication:
- 客户端向服务器请求数据:
Get /index.html HTTP/1.0
Host:www.google.com
- 服务器向客户端发送验证请求代码
401
WWW-Authenticate: Basic realm="google.com"
HTTP/1.0 401 Unauthorized
Server: SokEvo/1.0
WWW-Authenticate: Basic realm="google.com"
Content-Type: text/html
Content-Length: xxx
- 当符合 HTTP/1.0 或 HTTP/1.1 的客户端收到 401 返回值时, 将自动弹出一个登录窗口, 要求用户输入用户名和密码.
- 用户输入用户名和密码后, 将用户名及密码以 BASE64 加密方式加密, 并将密文放入前一条请求信息中
- 服务器收到上述请求信息后, 将 Authorization 字段后的用户信息取出/解密, 将解密后的用户名及密码与用户数据库进行比较验证
Get /index.html HTTP/1.0
Host: www.google.com
Authorization: Basic d2FuZzp3YW5n==
Session Cookie
Session Cookie Basis
HTTP 协议是一个无状态的协议,
服务器不会知道到底是哪一台浏览器访问了它,
因此需要一个标识用来让服务器区分不同的浏览器.
Cookie 就是这个管理服务器与客户端之间状态的标识.
Response header with Set-Cookie
, Request header with Cookie
.
浏览器第一次访问服务端, 服务端就会创建一次 Session, 在会话中保存标识该浏览器的信息. Session 缓存在服务端, Cookie 缓存在客户端, 他们都由服务端生成, 实现 HTTP 协议的状态.
- 客户端发送登录信息 (ID, Password).
- 服务器收到客户端首次请求并验证成功后,
会在服务器端创建 Session 并保存唯一的标识字符串 Session ID (Key-Value Store),
在 Response Header 中设置
Set-Cookie: <Session ID>
. - 客户端后续发送请求都需在 Request Header 中设置:
Cookie: <Session ID>
. - 服务器根据
<Session ID>
进行用户验证, 利用 Session Cookie 机制可以简单地实现用户登录状态验证, 保护需要登录权限才能访问的路由服务. Max-Age
priority higher thanExpires
. When both tonull
, cookie become session cookie.
Set-Cookie: username=tazimi; domain=tazimi.dev; Expires=Wed, 21 Oct 2022 08:00:00
Set-Cookie: username=tazimi; domain=tazimi.dev; path=/blog
Set-Cookie: username=tazimi; domain=tazimi.dev; path=/blog; Secure; HttpOnly
Set-Cookie: username=tazimi; domain=github.com
Set-Cookie: height=100; domain=me.github.com
Set-Cookie: weight=100; domain=me.github.com
Session Cookie Cons
- 认证方式局限于在浏览器 (Cookie).
- 非 HTTPS 协议下使用 Cookie, 容易受到 CSRF 跨站点请求伪造攻击.
- Session ID 不包含具体用户信息, 需要 Key-Value Store (e.g Redis) 持久化, 在分布式环境下需要在每个服务器上都备份, 占用了大量的存储空间.
Token Authentication
Token Authentication Basis
- 客户端发送登录信息 (ID, Password).
- 服务端收到请求验证成功后, 服务端会签发一个 Token (包含用户信息) 并发送给客户端.
- 客户端收到 Token 后存储到 Cookie 或 Local Storage,
客户端每次向服务端请求都需在 Request Header 中设置:
Authorization: <Token>
. - 服务端收到请求并验证 Token, 成功发送资源 (鉴权成功), 不成功发送 401 错误代码 (鉴权失败).
Token Authentication Pros
- Token 认证不局限于浏览器 (Cookie).
- 不使用 Cookie 可以规避 CSRF 攻击.
- Token 中包含了用户信息, 不需要 Key-Value Store 持久化, 分布式友好. 服务器端变成无状态, 服务器端只需要根据定义的规则校验 Token 合法性. 上述两点使得 Token Authentication 具有更好的扩展性.
Token Authentication Cons
- Token 认证 (加密解密过程) 比 Session Cookie 更消耗性能.
- Token (包含用户信息) 比 Session ID 大, 更占带宽.
- 不保存 Session 状态, 无法中止或更改 Token 权限, Token 到期前会始终有效, 存在盗用风险:
- Token 有效期应短.
- Token 应使用 HTTPS 协议.
- 对于重要权限, 需使用二次验证 (Two Factor Authentication).
JSON Web Token
JSON Web Tokens is small, object-friendly (compared to SAML, Security Assertion Markup Language Tokens) and security for public/private key pair (compared to SWT, Simple Web Tokens).
JSON Web Token Basis
- 基于 Token 的解决方案中最常用的是 JWT.
- 服务器认证用户密码以后, 生成一个 JSON 对象并签名加密后作为 Token 返回给用户.
- JSON 对象包含用户信息, 用户身份, 令牌过期时间等:
- Header: 明文 Base64 编码 JSON 对象, 描述 JWT 的元数据.
一般为 Token 的加密算法和 Token 的类型, 如
{"alg": "HS256","typ": "JWT"}
. - Payload: 明文 Base64 编码 JSOn 对象, 存放实际数据. 有 7 个官方字段和部分定义私有字段, 一般存放用户名, 用户身份, JWT 描述字段.
- Signature: 对 Header 和 Payload 的签名, 利用签名验证信息的正确性, 防止数据篡改. 签名需要服务端保存的密钥.
- Header: 明文 Base64 编码 JSON 对象, 描述 JWT 的元数据.
一般为 Token 的加密算法和 Token 的类型, 如
- 把三个部分拼成一个字符串, 每个部分之间用
.
分隔:HeaderBase64.PayloadBase64.Signature
. - 业务接口用来鉴权的 token 为
access token
. 越是权限敏感的业务,access token
有效期足够短, 以避免被盗用. - 一个专门生成
access token
的 token, 称为refresh token
.refresh token
用来获取access token
, 有效期更长, 通过独立服务和严格的请求方式增加安全性.
JSON Web TOken Pros
- JWT 默认是不加密.
- JWT 不加密的情况下, 不能将秘密数据写入 JWT.
- JWT 可以加密, 生成原始 Token 以后, 可以用密钥再加密一次.
- JWT 不仅可用于认证, 也可用于交换信息. 有效使用 JWT, 可以降低服务器查询数据库的次数.