Based on react to achieve the moo music style of music website, support PWA

Time:2020-9-16

GitHub address

Project website – pika music

Pika music API server refers to binaryfy’s Netease cloudmusic API

Technical characteristics of the project

  1. PWA support. PWA enabled browsers can be installed on the desktop
  2. Implement react SSR framework
  3. Implementation of dynamic import with SSR
  4. Implement webpack package and support module / nomudule mode
  5. Realize the whole station picture lazy loading

Other features:

  1. http2
  2. Android supports lock screen music control
  3. Banner carousel component
  4. Video and audio playback components

Website screenshot

Based on react to achieve the moo music style of music website, support PWA
Based on react to achieve the moo music style of music website, support PWA
Based on react to achieve the moo music style of music website, support PWA
Based on react to achieve the moo music style of music website, support PWA

Introduction to technical features

Introduction to react SSR framework

The main idea is nextjs. When rendering the first screen server, call the getinitialprops (store) method of the component to inject the Redux store. After getinitialprops gets the data of the page, it stores the data in the Redux store. In the client-side hydrate, the data is obtained from the Redux store, and then the data is injected into the initial data of SWR. The ability of SWR is used for data acquisition and update of subsequent pages. Non SSR pages use SWR directly.

Take discover as an example:
There is a parent class called connectcompreducer in the project

class ConnectCompReducer {
  constructor() {
    this.fetcher = axiosInstance
    this.moment = moment
  }

  getInitialData = async () => {
    throw new Error("child must implememnt this method!")
  }
}

Every page implementing SSR needs to inherit this class, such as the main page:

class ConnectDiscoverReducer extends ConnectCompReducer {
  //The getinitialprops method implemented by the discover page is to call getinitialdata and inject Redux store
  getInitialData = async store => {}
}

export default new ConnectDiscoverReducer()

Discover’s JSX:

import discoverPage from "./connectDiscoverReducer"

const Discover = memo(() => {
  //Banner data
  const initialBannerList = useSelector(state => state.discover.bannerList)

  //Inject banner data into the initialdata of SWR
  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's CTX
  await discoverPage.getInitialData(store, ctx)
}

Server side data acquisition:

//Matchedroutes: the matching route page should be combined with dynamic import, which will be introduced in the next section
const setInitialDataToStore = async (matchedRoutes, ctx) => {
  //Get Redux store
  const store = getReduxStore({
    config: {
      ua: ctx.state.ua,
    },
  })

  //After 600 ms, it will time out and interrupt to obtain data
  await Promise.race([
    Promise.allSettled(
      matchedRoutes.map(item => {
        return Promise.resolve(
          //Call the getinitialprops method of the page
          item.route?.component?.getInitialProps?.(store, ctx) ?? null,
        )
      }),
    ),
    new Promise(resolve => setTimeout(() => resolve(), 600)),
  ]).catch(error => {
    console.error("renderHTML 41,", error)
  })

  return store
}

Dynamic import with SSR

To encapsulate the dynamic import of a page, it is important to handle retry after loading errors and to avoid flashing page 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,
      })
      //Race loading to avoid page loading flashing
      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
    //Loading error, retry
    if (error) return loading({ error, retry: this.load })
    if (isTimeout) return loading({ pastDelay: true })

    if (Comp) return <Comp {...this.props} />
    return null
  }
}

Mark dynamically loaded components for server-side identification:

const asyncLoader = ({ loader, loading, pastDelay }) => {
  const importable = props => (
    <Loadable
      loader={loader}
      loading={loading}
      pastDelay={pastDelay}
      {...props}
    />
  )

  //Marking
  importable.isAsyncComp = true

  return importable
}

After encapsulating the dynamic loading of the page, two points need to be considered:

  1. In SSR, you need to take the initiative to execute dynamic routing components, otherwise the server will not render the contents of the components themselves
  2. If the browser does not load the dynamic split component first, the loading status of the component will flash. Therefore, it is necessary to load the dynamic routing component before rendering the page.

The specific codes are as follows:

The server loads the dynamic component marked 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
}

Browser side loading dynamic components:

const clientPreloadReady = async routes => {
  try {
    //Components that match the current page
    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)
  }
}

Finally, on the browser side ReactDOM.hydrate First load the dynamically separated components

clientPreloadReady(routes).then(() => {
  render(<App store={store} />, document.getElementById("root"))
})

Module / nomudule mode

The main implementation idea: webpack is based on webpack.client.js The configuration of ES module is packaged to support es module, and the output index.html 。 Then webpack uses webpack.client.lengacy . JS configuration, using the previous step index.html For template, package the code that does not support es module and insert script nomodule and script type = module. It mainly relies on the related hooks of HTML webpack plugin. webpack.client.js And webpack.client.lengacy The main difference between. JS is the configuration of Babel and the template of HTML webpack plugin

Babel presets configuration:

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
}

To implement the code link of the webpack plug-in code of script nomodule and script type = module in HTML: https://github.com/mbaxszy7/p…

Lazy loading of all station pictures

The implementation of image lazy loading uses intersectionobserver and image lazy loading supported by browser

const pikaLazy = options => {
  //If the browser supports lazy loading of images, it will set lazy loading of current pictures
  if ("loading" in HTMLImageElement.prototype) {
    return {
      lazyObserver: imgRef => {
        load(imgRef)
      },
    }
  }

  //If the current picture appears in the current viewport, the image will be loaded
  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 {
    //Set up observation picture
    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’s cache control and update capabilities use the workbench. But the logic of cache deletion is added

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 => {
            //Delete caches that do not match the current cache
            return caches.delete(cacheName)
          }),
      )
    }),
  )
})

The PWA cache control strategy of the project mainly selects stale while revalidate. The cache is displayed first (if any), and then PWA will update the cache. Because SWR is used in the project, the library will poll the data of the page or request to update the data when the page is hidden to display, so as to achieve the purpose of using PWA to update the cache.

Browser compatibility

IOS >=10,
Andriod >=6

Finally, if it’s helpful for your study of react, please ask star to address GitHub