百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

基于React SSR实现的仿MOO音乐风格网站,支持PWA

ccwgpt 2024-11-03 12:46 28 浏览 0 评论

前言

pika-music api 服务器参考 Binaryify 的 NeteaseCloudMusicApi

github : https://github.com/mbaxszy7/pika-music

项目技术特点:

  1. PWA 支持。支持PWA的浏览器可以安装到桌面
  2. 实现 React-SSR 框架
  3. 实现结合 SSR 的 Dynamic Import
  4. 实现 webpack module/nomudule 模式的打包
  5. 实现全站图片懒加载

node后端采用koa

其他特点:

  1. 后端支持http2
  2. 安卓端支持锁屏音乐控制

网站截图

技术特点介绍

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
}

封装好页面的动态加载后需要考虑两点:

  1. ssr 的时候需要主动去执行动态路由的组件,不然服务端不会渲染组件本身的内容
  2. 在浏览器端不先去加载动态 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.htmltemplate,打包出不支持 es module 的代码,插入 script nomodulescript type="module" 的脚本。主要依赖的是 html webpack plugin 的相关 hooks

webpack.client.jswebpack.client.lengacy.js 主要的不同是 babel 的配置和 html webpack plugintemplate

babel 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 loading

const 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)
      }
    },
  }
}

PWA

PWA 的缓存控制和更新的能力运用的是 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 模式

  1. npm run build:server
  2. npm run build:client:modern
  3. nodemon --inspect ./server\_app/bundle.js

本地开发开启 CSR 模式

npm run start:client

相关推荐

后端开发必看!Spring Boot3 如何轻松整合 MyBatis 管理 Mapper 接口?

你是否曾在使用SpringBoot3进行后端开发时,面对MyBatis管理Mapper接口一头雾水?项目工期紧张,却因为整合问题迟迟无法推进数据层开发,相信不少互联网大厂的后端开发人员都...

每天一个 Python 库:Django全能Web框架,一站式后台开发

为什么选择Django?Django=高效+全能+开箱即用内置功能说明ORM数据库操作更高效Admin管理后台1分钟搭好Auth登录认证/权限管理自动搞定路由、表单、静态资源通通内置...

作为后端开发,你知道MyBatis有哪些隐藏的 “宝藏” 扩展点吗?

在互联网大厂后端开发领域,MyBatis作为一款主流的持久层框架,凭借其灵活的配置与强大的数据处理能力,广泛应用于各类项目之中。然而,随着业务场景日趋复杂、系统规模不断扩张,开发过程中常面临SQL...

后端必看!Spring Boot3 跨域难题的五种破局之道

你在SpringBoot3开发中遇到过跨域问题吗?相信不少后端开发小伙伴都有过这样的经历:精心编写的接口,在前端调用时却突然报错,页面控制台跳出一堆关于跨域的错误提示,让人抓耳挠腮。别着急,今天...

这些 Python 后端技术竟成互联网大厂‘敲门砖’,你掌握了几个?

你是不是经常在技术群里刷到同行的“凡尔赛”发言?“刚用XX技术搞定大厂项目,offer直接到手”;又或者满心期待点开大厂招聘JD,却被Python后端那密密麻麻的技术要求,看得头皮发...

后端开发必读,一文带你了解如何配置MyBatis?

作为互联网大厂的后端开发人员,MyBatis想必是大家在项目开发中频繁使用的得力工具。不过,在实际操作中,你是否常常遭遇这些棘手状况:明明严格依照教程完成了MyBatis的配置,可项目启动时却无...

作为干电气的,低压断路器你真的了解吗?

低压断路器按结构型式分为万能式和塑料外壳式两类,本文主要介绍塑料式外壳小型断路器,小型断路器生厂厂家有很多,常见的为DZ型为中法合资的梅兰日兰,其余还有ABB公司的E系列F系列S系列,西门子的3...

高低压成套电气设备(五)(高低压成套设备技术的标准)

KYN28-12型户内金属铠装抽出式开关柜概述:KYN28-12(GZS1-12)型户内金属铠装抽出式开关柜系3~10KV三相交流50Hz单母线及单母线分段系统的成套配电装置。主要用于发电厂、中小型发...

深入解析:进线柜、出线柜等六种配电柜的组成和作用

配电柜家族深度解析:进线、出线、计量及其他从高压变电站到千家万户,电力如何安全可靠地输送?答案在于配电系统,而配电柜正是这个系统的核心组成部分,是电力系统心脏的守护者。本文将深入剖析六种常见配电柜——...

自主开发&quot;开关机械特性测试数据智能分析工具&quot;提升检修质量

5月9日,南网超高压公司天生桥局依托"大瓦特"智能平台天生桥局检修人员通过自主创新,成功开发出"开关机械特性测试数据智能分析工具",该成果标志着天生桥局在业技融合与数字化转型方面取得了重要突破。该分析...

烧焦后的电闸箱终于改完了,最终选择了安全实用的方案。

本内容来源于@什么值得买APP,观点仅代表作者本人|作者:端木先生R几个月前,我家电闸箱零线排烧焦,当时真的有点小慌。后来找朋友研究半天,站内很多朋友给了不少宝贵意见,终于上周改造完了。十分感谢大伙...

什么是断路器的“壳架电流”?和“额定电流”有什么关系?

【我是头条创作者“振生的电气知识角”,欢迎在评论区留言,▲左上角点击+“关注”】小伙伴儿们,你们是不是会有疑问:断路器的“壳架电流”和脱扣器的“整定电流”什么关系?二者是否是一回事儿呢?接下来,我们...

断路器各种电流之间的关系是什么?作为电气人都需要掌握的知识!

提起断路器,想必大部分电气人员都不陌生了,但是如果说到断路器各种电流之间的关系,可能不少电气人员都是云里有雾摸不着头脑,甚至还有一些电气人员是一问三不知的。我们都知道断路器是指能够关合、承载和开断正常...

ABB高压授权柜UniSafe 提供尽可能多的供配电回路

ABB高压开关柜UniSafe以其环保、紧凑的设计而著称,不仅结构紧凑、空间利用率高,还能在有限的占地面积内提供尽可能多的供配电回路,大大节省了资源和土建费用。内部结构部件采用优质材料,如敷铝锌钢板或...

如何选择空气开关,详细介绍分析(如何选择空气开关,详细介绍分析方法)

空气开关C型与D型的选择需根据负载特性、电流冲击需求和应用场景综合判断,以下是具体分析:一、核心区别空气开关结构简图施耐德空开西门子空开ABB空开ABB塑料外壳断路器施耐德塑料外壳断路器施耐德塑料外壳...

取消回复欢迎 发表评论: