Github address: github.com/bbwlfx/ts-b…

First page

Once the Demo is configured, start developing a simple Demo page. First, define the Demo model:

models/demo.ts
import { demoModalState } from "typings";
import { createModel } from "@rematch/core";

export const demo = createModel({
  state: ({
    outstr: "Hello World".count: 10
  } as any) as demoModalState,
  reducers: {
    "@init": (state: demoModalState, init: demoModalState) = > {
      state = init;
      return state;
    },
    add(state: demoModalState, num) {
      state.count = state.count + (num || 1);
      return state;
    },
    reverse(state: demoModalState) {
      state.outstr = state.outstr
        .split("")
        .reverse()
        .join("");
      returnstate; }}});Copy the code

Place all defined interfaces under the Typings directory.

typings/state/demo.d.ts
exportinterface demoModalState { count? : number; outstr? : string; }Copy the code

Then write the container component:

containers/demo/index.tsx
import React, { Component } from "react";
import { connect } from "react-redux";
import { Button } from "antd";
import { DemoProps } from "typings";
import utils from "lib/utils";
import "./demo.scss";

class Demo extends Component<DemoProps> {
  static defaultProps: DemoProps = {
    count: 0.outstr: "Hello World".Add: (a)= > void {},
    Reverse: (a)= > void{}};constructor(props) {
    super(props);
  }
  
  render() {
    const { Add, Reverse, count, outstr } = this.props;
    return (
      <div>
        <Button type="primary" onClick={Reverse}>
          click me to Reverse words
        </Button>
        <span className="output">{outstr}</span>
        <Button onClick={()= > Add(1)}>click me to add number</Button> now
        number is : {count}
      </div>); }}const mapStateToProps = (store: any) = > ({
  ...store.demo,
  url: store.common.url
});
const mapDispatchToProps = (dispatch: any) = > ({
  Add: dispatch.demo.add,
  Reverse: dispatch.demo.reverse
});
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Demo);
Copy the code

Finally, register the component into the route and you are done:

entry/home/routes.tsx
import Loadable from "react-loadable";
import * as Path from "constants/path";
import Loading from "components/loading";

export default[{name: "demo".path: Path.Demo,
    component: Loadable({
      loader: (a)= > import("containers/demo"),
      loading: Loading
    }),
    exact: true}];Copy the code

Path.Demo is a defined constant with a value of/Demo.

After the front-end component is written, don’t forget the routing and SSR code in the corresponding node.

/src/routes/index.ts
import Router from "koa-router";
import homeController from "controllers/homeController";

const router = Router();

router.get("/demo", homeController.demo);

export default router;
Copy the code

Next comes the homeController file for business processing:

src/controllers/homeController.tsx
import getPage from ".. /utils/getPage";
import { Entry, configureStore } from ".. /public/buildServer/home";

interface homeState {
  demo: (ctx: any) = > {};
}
const home: homeState = {
  async demo(ctx) {
    const store = configureStore({
      demo: {
        count: 10.outstr: "Hello World!"}});const page = await getPage({
      store,
      url: ctx.url,
      Component: Entry,
      page: "home".model: "demo"}); ctx.render(page); }};export default home;
Copy the code

Good! The first SSR page is done!

Then start the package and visit the page

$ npm run startfe
$ npm run start
Copy the code

Note that the SSR code in Node needs to use the front-end packaged product, so running start before startfe has finished will cause an error!

Finally, visit the localhost:7999/demo page to see the effect.

Todolist page

After the first page is built, we can write a complex Todolist page to check the SPA effect of the React-Router and improve the subsequent first-screen data loading issues.

Define the model first:

models/todolist.ts
import { createModel } from "@rematch/core";
import { todoListModal } from "typings";
export const todolist = createModel({
  state: ({
    list: []}as any) as todoListModal,
  reducers: {
    "@init": (state: todoListModal, init: todoListModal) = > {
      state = init;
      return state;
    },
    deleteItem: (state: todoListModal, id: string) = > {
      state.list = state.list.filter(item= >item.id ! == id);return state;
    },
    addItem: (state: todoListModal, text: string) = > {
      const id = Math.random()
        .toString(16)
        .slice(2);
      state.list.push({
        id,
        text
      });
      returnstate; }},effects: dispatch= > ({
    async asyncDelete(id: string) {
      await new Promise(resolve= > {
        setTimeout((a)= > {
          resolve();
        }, 1000);
      });
      dispatch.todolist.deleteItem(id);
      return Promise.resolve(); }})});Copy the code

This is all you need to create a previously complex React-Redux version of Todolist. @rematch is very friendly!

Next, write a simple Todolist page:

containers/todolist/index.tsx
import React, { Component } from "react";
import { connect } from "react-redux";
import { todolistProps, todolistState } from "typings";
import utils from "lib/utils";
import "./todolist.scss";

class Todolist extends Component<todolistProps.todolistState> {
  constructor(props) {
    super(props);
    this.state = {
      text: ""
    };
    utils.bindMethods(
      ["addItem"."changeInput"."deleteItem"."asyncDelete"].this
    );
  }

  addItem() {
    const { text } = this.state;
    this.props.addItem(text);
    this.setState({
      text: ""
    });
  }

  deleteItem(id: string) {
    this.props.deleteItem(id);
  }

  asyncDelete(id: string) {
    this.props.asyncDelete(id);
  }
  changeInput(e) {
    this.setState({
      text: e.target.value
    });
  }
  render() {
    const { list = [] } = this.props;
    const { text } = this.state;
    return( <> <input className="input" value={text} onChange={this.changeInput} /> <button onClick={this.addItem}>Add</button> <ol className="todo-list"> {list.map(item => { return ( <li className="todo-item" key={item.id}> <span>{item.text}</span> <button onClick={() => this.deleteItem(item.id)}>delete</button> <button onClick={() => this.asyncDelete(item.id)}> async delete </button> </li> ); })} </ol> </> ); } } const mapStateToProps = store => { return { ... store.todolist }; }; const mapDispatchToProps = dispatch => { return { ... dispatch.todolist }; }; export default connect( mapStateToProps, mapDispatchToProps )(Todolist);Copy the code

Then don’t forget to register components for both front-end and back-end routes:

js/entry/home/routes.tsx
import Loadable from "react-loadable";
import * as Path from "constants/path";
import Loading from "components/loading";

export default[{name: "demo".path: Path.Demo,
    component: Loadable({
      loader: (a)= > import("containers/demo"),
      loading: Loading
    }),
    exact: true
  },
  {
    name: "todolist".path: Path.Todolist,
    component: Loadable({
      loader: (a)= > import("containers/todolist"),
      loading: Loading
    }),
    exact: true}];Copy the code

Path.Todolist is a defined constant with a value of /.

src/routes/index.ts
import Router from "koa-router";
import homeController from "controllers/homeController";

const router = Router();

router.get("/", homeController.index);
router.get("/demo", homeController.demo);

export default router;
Copy the code

To complete the global Layout component, add two public routes:

js/components/layout/index.tsx
import React, { Component } from "react";
import { Link } from "react-router-dom";
import * as Path from "constants/path";

export default class Layout extends Component {
  render() {
    return (
      <>
        <h4>
          <Link to={Path.Todolist}>Todo List</Link>
        </h4>
        <h4>
          <Link to={Path.Demo}>demo</Link>
        </h4>
        <div>{this.props.children}</div>
      </>); }}Copy the code

Then visit our page and you’ll see that there are two resident routes at the top for us to switch between

First screen data loading

The first screen data is pre-loaded in Node for the first page visited. Other pages do not have data preloaded.

Thanks to the convenience of @rematch/ Dispatch, we can define a common set of prefetchData() functions for pulling data from the first screen for each model.

Therefore, we will transform L for both models

models/todolist.ts
import { createModel } from "@rematch/core";
import { todoListModal } from "typings";
export const todolist = createModel({
  state: ({
    list: []}as any) as todoListModal,
  reducers: {
    "@init": (state: todoListModal, init: todoListModal) = > {
      state = init;
      return state;
    },
    deleteItem: (state: todoListModal, id: string) = > {
      state.list = state.list.filter(item= >item.id ! == id);return state;
    },
    addItem: (state: todoListModal, text: string) = > {
      const id = Math.random()
        .toString(16)
        .slice(2);
      state.list.push({
        id,
        text
      });
      returnstate; }},effects: dispatch= > ({
    async asyncDelete(id: string) {
      await new Promise(resolve= > {
        setTimeout((a)= > {
          resolve();
        }, 1000);
      });
      dispatch.todolist.deleteItem(id);
      return Promise.resolve();
    },
    async prefetchData(init) {
      dispatch.todolist["@init"](init);
      return Promise.resolve(); }})});Copy the code
models/demo.ts
import { demoModalState } from "typings";
import { createModel } from "@rematch/core";

export const demo = createModel({
  state: ({
    outstr: "Hello World".count: 10
  } as any) as demoModalState,
  reducers: {
    "@init": (state: demoModalState, init: demoModalState) = > {
      state = init;
      return state;
    },
    add(state: demoModalState, num) {
      state.count = state.count + (num || 1);
      return state;
    },
    reverse(state: demoModalState) {
      state.outstr = state.outstr
        .split("")
        .reverse()
        .join("");
      returnstate; }},effects: dispatch= > ({
    async prefetchData() {
      const number = await new Promise(resolve= > {
        setTimeout((a)= > {
          console.log("prefetch first screen data!");
          resolve(13);
        }, 1000);
      });
      dispatch.demo.add(number);
      return Promise.resolve(); }})});Copy the code

With the prefetchData function, we can directly call this function when the node is doing SSR to load the first screen of data.

src/utils/getPage.tsx
import { getBundles } from "react-loadable/webpack";
import React from "react";
import { getScript, getStyle } from "./bundle";
import { renderToString } from "react-dom/server";
import Loadable from "react-loadable";

export default async function getPage({ store, url, Component, page, model, params = {} }) {
  const manifest = require(".. /public/buildPublic/manifest.json");
  const mainjs = getScript(manifest[`${page}.js`]);
  const maincss = getStyle(manifest[`${page}.css`]);

  if(! Component && ! store) {return {
      html: "".scripts: mainjs,
      styles: maincss,
      __INIT_STATES__: "{}"
    };
  }

  let modules: string[] = [];

  constdom = ( <Loadable.Capture report={moduleName => { modules.push(moduleName); }} > <Component url={url} store={store} /> </Loadable.Capture> ); // prefetch first screen data if (store.dispatch[model] && store.dispatch[model].prefetchData) { await store.dispatch[model].prefetchData(params); } const html = renderToString(dom); const stats = require(".. /public/buildPublic/react-loadable.json"); let bundles: any[] = getBundles(stats, modules); const _styles = bundles .filter(bundle => bundle && bundle.file.endsWith(".css")) .map(bundle => getStyle(bundle.publicPath)) .concat(maincss); const styles = [...new Set(_styles)].join("\n"); const _scripts = bundles .filter(bundle => bundle && bundle.file.endsWith(".js")) .map(bundle => getScript(bundle.publicPath)) .concat(mainjs); const scripts = [...new Set(_scripts)].join("\n"); return { html, __INIT_STATES__: JSON.stringify(store.getState()), scripts, styles }; }Copy the code

Here we have two additional parameters — model and params — representing the current Model and the parameters to be passed to the prefetchData function.

We are then done with the call to getPage in homeController:

src/controllers/homeController.tsx
import getPage from ".. /utils/getPage";
import { Entry, configureStore } from ".. /public/buildServer/home";

interface homeState {
  index: (ctx: any) = > {};
  demo: (ctx: any) = > {};
}
const home: homeState = {
  async index(ctx) {
    const store = configureStore({
      todolist: {
        list: []}});const page = await getPage({
      store,
      url: ctx.url,
      Component: Entry,
      page: "home".model: "todolist".params: {
        list: [{id: "hello".text: "node prefetch data"}}}]); ctx.render(page); },async demo(ctx) {
    const store = configureStore({
      demo: {
        count: 10.outstr: "Hello World!"}});const page = await getPage({
      store,
      url: ctx.url,
      Component: Entry,
      page: "home".model: "demo"}); ctx.render(page); }};export default home;
Copy the code

With all the work in place, open up our website again, go to localhost:7999, and you can load the first screen data smoothly.

Optimized data loading on the first screen

We don’t want to pull data only for nodes, we want to load the first screen for nodes, but only after componentDidMount, so we need to modify the demo component:

containers/demo.tsx

// ...
componentDidMount() {
    this.props.prefetchData();
}
// ...
Copy the code

After the modification, we found that when the first screen loaded the /todolist page, the front end switched to the /demo page, and after a while prefetchData() function was successfully triggered, and count changed to 23.

However, when we went to the /demo page, we found that after the first node data load, the initial value of count was 23, and after a while, after the prefetchData() is executed, the initial value of count is 36, which is not what we expected, so we need to optimize the first screen data load.

We need to determine which page did the first screen load, and when that page has already done the first screen load, didmount stops loading data.

So I thought of a couple of ways to do this, and I finally chose to record the URL.

Add a common model: common

models/common.ts
import { CommonModelState } from "typings";
import { createModel } from "@rematch/core";

export const common = createModel({
  state: ({} as any) as CommonModelState,
  reducers: {
    "@init": (state: CommonModelState, init: CommonModelState) = > {
      state = init;
      returnstate; }}});Copy the code

Then inject the URL into the common model when the homeController initializes the store:

homeController.ts
const store = configureStore({
    common: {
        url: ctx.url
    },
    // ...
});
Copy the code

So we can use the URL parameter in the common model to get the page that has been loaded on the first screen. Then we can modify the connect part of the container and inject the URL parameter into the props:

containers/demo/index.tsx
const mapStateToProps = (store: any) = > ({
  ...store.demo,
  url: store.common.url
});
Copy the code

Next, write a pull function in utils to determine whether to pull the data based on the current location and props. Url.

js/lib/utils.ts
const utils = {
  // ...
  fetchData(props, fn) {
    const { location, url } = props;
    if(! location || ! url) { fn();return;
    }
    if(location.pathname ! == url) { fn(); }}};export default utils;
Copy the code

Add the fetchData function to each container:

componentDidMount() {
    utils.fetchData(this.props, this.props.prefetchData);
}
Copy the code

At this point, the first attempt of SPA+SSR+ isomorphism at the front and back ends was completed!

Series of articles:

  1. React Best Practices (I) Technology selection
  2. React Best Practices (part 2)