前言pika-music api 服務器參考 Binaryify 的 NeteaseCloudMusicApigithub : https://github.com/mbaxszy7/pika-music項目技術特點:PWA 支持。支持PWA的瀏覽器可以安裝到桌面實現 React-SSR 框架實現結合 SSR 的 Dynamic Import實現 webpack module/nomudule 模式的打包實現全站圖片懶加載node後端采用koa其他特點:後端支持http2安卓端支持鎖屏音樂控制網站截圖技術特點介紹React-SSR 框架介紹主要思想參考的是 NextJS。首屏服務端渲染時,調用組件的 getInitialProps(store)方法,註入 redux store,getInitialProps 獲取該頁面的數據後,把數據儲存到 redux store 中。在客戶端 hydrate 時,從 redux store 中獲取數據,然後把數據註入swr的 initialData 中,後續頁面的數據獲取和更新就使用瞭 swr 的能力。非 SSR 的頁面會直接使用 swr。下面以首頁(Discover)為例:項目中有 ConnectCompReducer 這個父類:class ConnectCompReducer {
constructor() {
this.fetcher = axiosInstance
this.moment = moment
}
getInitialData = async () => {
throw new Error("child must implememnt this method!")
}
}
每個實現 SSR 的頁面都需要繼承這個類,比如主頁面:class ConnectDiscoverReducer extends ConnectCompReducer {
// Discover 頁面會實現的getInitialProps方法就是調用getInitialData,註入redux store
getInitialData = async store => {}
}
export default new ConnectDiscoverReducer()
Discover 的 JSX:import discoverPage from "./connectDiscoverReducer"
const Discover = memo(() => {
// banner 數據
const initialBannerList = useSelector(state => state.discover.bannerList)
// 把banner數據註入swr的initialData中
const { data: bannerList } = useSWR(
"/api/banner?type=2",
discoverPage.requestBannerList,
{
initialData: initialBannerList,
},
)
return (
…
<BannersSection>
<BannerListContainer bannerList={bannerList ?? []} />
</BannersSection>
…
)
})
Discover.getInitialProps = async (store, ctx) => {
// store -> redux store, ctx -> koa 的ctx
await discoverPage.getInitialData(store, ctx)
}
服務端數據的獲取:// matchedRoutes: 匹配到的路由頁面,需要結合dynamic import,下一小節會介紹
const setInitialDataToStore = async (matchedRoutes, ctx) => {
// 獲取redux store
const store = getReduxStore({
config: {
ua: ctx.state.ua,
},
})
// 600ms後超時,中斷獲取數據
await Promise.race([
Promise.allSettled(
matchedRoutes.map(item => {
return Promise.resolve(
// 調用頁面的getInitialProps方法
item.route?.component?.getInitialProps?.(store, ctx) ?? null,
)
}),
),
new Promise(resolve => setTimeout(() => resolve(), 600)),
]).catch(error => {
console.error("renderHTML 41,", error)
})
return store
}
自行實現結合 SSR 的 Dynamic Import頁面 dynamic import 的封裝, 重要的處理是加載錯誤後的 retry 和 避免頁面 loading 閃現:class Loadable extends React.Component {
constructor(props) {
super(props)
this.state = {
Comp: null,
error: null,
isTimeout: false,
}
}
// eslint-disable-next-line react/sort-comp
raceLoading = () => {
const { pastDelay } = this.props
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("timeout")), pastDelay || 200)
})
}
load = async () => {
const { loader } = this.props
try {
this.setState({
error: null,
})
// raceLoading 避免頁面loading 閃現
const loadedComp = await Promise.race([this.raceLoading(), loader()])
this.setState({
isTimeout: false,
Comp:
loadedComp && loadedComp.__esModule ? loadedComp.default : loadedComp,
})
} catch (e) {
if (e.message === "timeout") {
this.setState({
isTimeout: true,
})
this.load()
} else {
this.setState({
error: e,
})
}
}
}
componentDidMount() {
this.load()
}
render() {
const { error, isTimeout, Comp } = this.state
const { loading } = this.props
// 加載錯誤,retry
if (error) return loading({ error, retry: this.load })
if (isTimeout) return loading({ pastDelay: true })
if (Comp) return <Comp {…this.props} />
return null
}
}
標記動態加載的組件,用於服務端識別:const asyncLoader = ({ loader, loading, pastDelay }) => {
const importable = props => (
<Loadable
loader={loader}
loading={loading}
pastDelay={pastDelay}
{…props}
/>
)
// 標記
importable.isAsyncComp = true
return importable
}
封裝好頁面的動態加載後需要考慮兩點:ssr 的時候需要主動去執行動態路由的組件,不然服務端不會渲染組件本身的內容在瀏覽器端不先去加載動態 split 出的組件的話,會導致組件的 loading 狀態閃現。所以,要先加載好動態路由組件,再去渲染頁面。具體代碼如下:服務端加載標記 isAsyncComp 的動態組件:const ssrRoutesCapture = async (routes, requestPath) => {
const ssrRoutes = await Promise.allSettled(
[…routes].map(async route => {
if (route.routes) {
return {
…route,
routes: await Promise.allSettled(
[…route.routes].map(async compRoute => {
const { component } = compRoute
if (component.isAsyncComp) {
try {
const RealComp = await component().props.loader()
const ReactComp =
RealComp && RealComp.__esModule
? RealComp.default
: RealComp
return {
…compRoute,
component: ReactComp,
}
} catch (e) {
console.error(e)
}
}
return compRoute
}),
).then(res => res.map(r => r.value)),
}
}
return {
…route,
}
}),
).then(res => res.map(r => r.value))
return ssrRoutes
}
瀏覽器端加載動態組件:const clientPreloadReady = async routes => {
try {
// 匹配當前頁面的組件
const matchedRoutes = matchRoutes(routes, window.location.pathname)
if (matchedRoutes && matchedRoutes.length) {
await Promise.allSettled(
matchedRoutes.map(async route => {
if (
route?.route?.component?.isAsyncComp &&
!route?.route?.component.csr
) {
try {
await route.route.component().props.loader()
} catch (e) {
await Promise.reject(e)
}
}
}),
)
}
} catch (e) {
console.error(e)
}
}
最後,在瀏覽器端 ReactDOM.hydrate 的時候先加載動態分割出的組件:clientPreloadReady(routes).then(() => {
render(<App store={store} />, document.getElementById("root"))
})
module/nomudule 模式主要實現思路:webpack 先根據 webpack.client.js 的配置打包出支持 es module 的代碼,其中產出 index.html。然後 webpack 根據 webpack.client.lengacy.js 的配置,用上一步的 index.html 為 template,打包出不支持 es module 的代碼,插入 script nomodule 和script type="module" 的腳本。主要依賴的是 html webpack plugin 的相關 hooks。webpack.client.js 和 webpack.client.lengacy.js 主要的不同是 babel 的配置和 html webpack plugin 的 templatebabel presets 配置:exports.babelPresets = env => {
const common = [
"@babel/preset-env",
{
// targets: { esmodules: true },
useBuiltIns: "usage",
modules: false,
debug: false,
bugfixes: true,
corejs: { version: 3, proposals: true },
},
]
if (env === "node") {
common[1].targets = {
node: "13",
}
} else if (env === "legacy") {
common[1].targets = {
ios: "9",
safari: "9",
}
common[1].bugfixes = false
} else {
common[1].targets = {
esmodules: true,
}
}
return common
}
實現在 html 內插入 script nomodule 和 script type="module"的 webpack 插件代碼鏈接:https://github.com/mbaxszy7/pika-music/blob/master/module-html-plugin.js全站圖片懶加載圖片懶加載的實現使用的是 IntersectionObserver 和瀏覽器原生支持的image lazy loadingconst pikaLazy = options => {
// 如果瀏覽器原生支持圖片懶加載,就設置懶加載當前圖片
if ("loading" in HTMLImageElement.prototype) {
return {
lazyObserver: imgRef => {
load(imgRef)
},
}
}
// 當前圖片出現在當前視口,就加載圖片
const observer = new IntersectionObserver(
(entries, originalObserver) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0 || entry.isIntersecting) {
originalObserver.unobserve(entry.target)
if (!isLoaded(entry.target)) {
load(entry.target)
}
}
})
},
{
…options,
rootMargin: "0px",
threshold: 0,
},
)
return {
// 設置觀察圖片
lazyObserver: () => {
const eles = document.querySelectorAll(".pika-lazy")
for (const ele of Array.from(eles)) {
if (observer) {
observer.observe(ele)
continue
}
if (isLoaded(ele)) continue
load(ele)
}
},
}
}
PWAPWA 的緩存控制和更新的能力運用的是 workbox。但是加瞭緩存刪除的邏輯:import { cacheNames } from "workbox-core"
const currentCacheNames = {
"whole-site": "whole-site",
"net-easy-p": "net-easy-p",
"api-banner": "api-banner",
"api-personalized-newsong": "api-personalized-newsong",
"api-playlist": "api-play-list",
"api-songs": "api-songs",
"api-albums": "api-albums",
"api-mvs": "api-mvs",
"api-music-check": "api-music-check",
[cacheNames.precache]: cacheNames.precache,
[cacheNames.runtime]: cacheNames.runtime,
}
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(cacheGroup => {
return Promise.all(
cacheGroup
.filter(cacheName => {
return !Object.values(currentCacheNames).includes(`${cacheName}`)
})
.map(cacheName => {
// 刪除與當前緩存不匹配的緩存
return caches.delete(cacheName)
}),
)
}),
)
})
項目的 PWA 緩存控制策略主要選擇的是 StaleWhileRevalidate,先展示緩存(如果有的話),然後 pwa 會更新緩存。由於項目用瞭 swr,該庫會查詢頁面的數據或者在頁面從隱藏到顯示時也會請求更新數據,從而達到瞭使用 pwa 更新的緩存的目的。瀏覽器兼容IOS >=10, Andriod >=6本地開發node 版本node version >= 13.8本地開發開啟 SSR 模式npm run build:servernpm run build:client:modernnodemon –inspect ./server\_app/bundle.js本地開發開啟 CSR 模式npm run start:client