Making the address

Project site

The Pika-Music API server references Binaryify’s NeteaseCloudMusicApi

Updated instructions

[2020-10-28] Project supports WebPack 5 packaging.

Project Technical features:

  1. PWA support. Pwa-enabled browsers can be installed to the desktop
  2. Implement react-SSR framework
  3. Implement Dynamic Import combined with SSR
  4. Implement the webPack Module/Nomudule pattern packaging
  5. Realize the whole station picture lazy loading

The node back-end uses KOA

Other features:

  1. The back end supports HTTP2
  2. Android supports lock screen music control

Website screenshot

Technical features

Introduction to the React-SSR framework

The main idea reference is NextJS. When the first screen is rendered, the server calls the getInitialProps(store) method of the component and injects the redux Store. GetInitialProps obtains the data on the page and stores it in the Redux Store. At the client hydrate, data is fetched from the Redux Store and injected into the SWR’s initialData. Subsequent page data fetching and updating uses the SWR’s capabilities. Non-ssr pages will use SWR directly.

ConnectCompReducer: ConnectCompReducer: ConnectCompReducer

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

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

Every page that implements SSR needs to inherit from this class, such as the main page:

class ConnectDiscoverReducer extends ConnectCompReducer {
  // The Discover page will implement getInitialProps by calling getInitialData and injecting the Redux Store
  getInitialData = async store => {}
}

export default new ConnectDiscoverReducer()
Copy the code

Discover the JSX:

import discoverPage from "./connectDiscoverReducer"

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

  // Inject the banner data into the 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)
}

Copy the code

Obtaining server data:

// matchedRoutes: the matched routing page, which needs to be combined with dynamic import, as described in the next section
const setInitialDataToStore = async (matchedRoutes, ctx) => {
  // Get the redux store
  const store = getReduxStore({
    config: {
      ua: ctx.state.ua,
    },
  })

  // timeout occurs after 600ms, and data acquisition is interrupted
  await Promise.race([
    Promise.allSettled(
      matchedRoutes.map(item= > {
        return Promise.resolve(
          // Call the page's getInitialProps methoditem.route? .component?.getInitialProps?.(store, ctx) ??null,)}),),new Promise(resolve= > setTimeout(() = > resolve(), 600)),
  ]).catch(error= > {
    console.error("renderHTML 41,", error)
  })

  return store
}
Copy the code

Implement Dynamic Import combined with SSR

To encapsulate page dynamic import, the important handling is to retry after loading error and avoid page loading flash:

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 avoids page loading flash
      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
    // Load error, retry
    if (error) return loading({ error, retry: this.load })
    if (isTimeout) return loading({ pastDelay: true })

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

Tags dynamically loaded components for server identification:

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

  / / tag
  importable.isAsyncComp = true

  return importable
}
Copy the code

After encapsulating the dynamic loading of a page, there are two considerations:

  1. SSR requires active implementation of dynamically routed components, otherwise the server will not render the components themselves
  2. Loading a dynamically split component without first loading it in the browser will cause the loading state of the component to flash. So load the dynamic routing component before rendering the page.

The specific code is 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
}
Copy the code

Load dynamic components on the browser side:

const clientPreloadReady = async routes => {
  try {
    // Match the components of 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)
  }
}
Copy the code

Finally, load the dynamically detached component first on the browser side with reactdom.hydrate:

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

The module/nomudule model

Main implementation idea: WebPack first packages the code that supports ES Module according to the configuration of Webpack.client.js, which produces index.html. Then webpack according to webpack. Client. Lengacy. Js configuration, use the step index. The HTML template, packaging does not support the es module code, Insert script Nomodule and script type=”module” script. The main dependencies are the hooks of the HTML WebPack Plugin. Webpack. Client. Js and webpack. Client. Lengacy. Js major difference is the configuration of Babel and HTML webpack plugin template

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
}
Copy the code

A webPack plugin that inserts script Nomodule and script type=”module” into HTML is linked to github.com/mbaxszy7/pi…

The whole station picture lazy loading

The implementation of lazy loading of images uses IntersectionObserver and image lazy loading supported by the browser

const pikaLazy = options= > {
  // If the browser natively supports lazy image loading, set the current image to lazy loading
  if ("loading" in HTMLImageElement.prototype) {
    return {
      lazyObserver: imgRef= > {
        load(imgRef)
      },
    }
  }

  // When the current image appears in the current viewport, the image is 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 the view image
    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)
      }
    },
  }
}
Copy the code

PWA

The PWA’s cache control and update capabilities use Workbox. But add cache delete logic:

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 the cache that does not match the current cache
            return caches.delete(cacheName)
          }),
      )
    }),
  )
})
Copy the code

The PWA cache control strategy for the project was to choose StaleWhileRevalidate, which first demonstrates the cache (if any) and then PWA updates the cache. Since the project uses SWR, the library polls the page data or requests updates as the page goes from hidden to displayed, thus achieving the purpose of caching the pWA updates.

Browser compatibility

IOS >=10, Andriod >=6

Local development

The node version

The node version > = 13.8

Enable SSR mode for local development

  1. npm run build:server
  2. npm run build:client:modern
  3. nodemon –inspect ./server_app/bundle.js

Enable the CSR mode for local development

npm run start:client

Finally, if it helps you learn react, please star itMaking the address🎉 🎉