React最佳實踐嘗試(三)

配置完畢之後,接下來就開始開發一個簡單的Demo頁面吧~ 首先要定義好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("");
      return state;
    }
  }
});
複製程式碼

將定義好的 interface 統一放到 typings 目錄下面。

typings/state/demo.d.ts

export interface demoModalState {
  count?: number;
  outstr?: string;
}
複製程式碼

然後編寫container元件即可:

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: () => void {},
    Reverse: () => 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);
複製程式碼

最後將元件註冊進路由中就大功告成了:

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: () => import("containers/demo"),
      loading: Loading
    }),
    exact: true
  }
];
複製程式碼

Path.Demo 是定義的常量,值為 /demo

前端元件寫完了之後,別忘了對應的node中的路由和ssr的程式碼。

/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;
複製程式碼

接下來就是業務處理的 homeController 文件了:

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;
複製程式碼

好!第一個SSR頁面大功告成!

接下來啟動打包之後訪問頁面即可

$ npm run startfe
$ npm run start
複製程式碼

注意,node中的ssr程式碼需要使用前端打包的產物,因此在 startfe 沒有結束之前執行 start 會報錯的!

最後訪問 localhost:7999/demo 頁面就可以檢視效果了。

todolist頁面

第一個頁面構建完畢之後,我們可以在寫一個複雜一點的todolist頁面來檢查一下 react-router 的spa效果,以及完善後續的首屏資料載入的問題。

依然是先定義model:

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
      });
      return state;
    }
  },
  effects: dispatch => ({
    async asyncDelete(id: string) {
      await new Promise(resolve => {
        setTimeout(() => {
          resolve();
        }, 1000);
      });
      dispatch.todolist.deleteItem(id);
      return Promise.resolve();
    }
  })
});
複製程式碼

只需要這些程式碼就可以完成一個以前十分複雜的react-redux版的todolist,是不是感覺@rematch非常友好!

接下來寫一個簡單的todolist頁面:

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);
複製程式碼

然後別忘了給前端和後端路由註冊元件:

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: () => import("containers/demo"),
      loading: Loading
    }),
    exact: true
  },
  {
    name: "todolist",
    path: Path.Todolist,
    component: Loadable({
      loader: () => import("containers/todolist"),
      loading: Loading
    }),
    exact: true
  }
];
複製程式碼

Path.Todolist 是定義的常量,值為 /

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;
複製程式碼

最後完善一下全域性的 Layout 元件,加上兩個公共路由即可:

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>
      </>
    );
  }
}
複製程式碼

然後再訪問我們的頁面,就可以看到頂部有兩個常駐的路由供我們切換了

React最佳實踐嘗試(三)
React最佳實踐嘗試(三)

至此spa+ssr的構建就完成了!

首屏資料載入

首屏資料即在node中提前載入訪問的第一個頁面的資料,其他頁面沒有資料的預載入。

得意於 @rematch/dispatch 的便利性,我們可以給每個 model 都定義一套公共的用於拉取首屏資料的函式 prefetchData()

因此我們給兩個 model 都改造一下L

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
      });
      return state;
    }
  },
  effects: dispatch => ({
    async asyncDelete(id: string) {
      await new Promise(resolve => {
        setTimeout(() => {
          resolve();
        }, 1000);
      });
      dispatch.todolist.deleteItem(id);
      return Promise.resolve();
    },
    async prefetchData(init) {
      dispatch.todolist["@init"](init);
      return Promise.resolve();
    }
  })
});
複製程式碼

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("");
      return state;
    }
  },
  effects: dispatch => ({
    async prefetchData() {
      const number = await new Promise(resolve => {
        setTimeout(() => {
          console.log("prefetch first screen data!");
          resolve(13);
        }, 1000);
      });
      dispatch.demo.add(number);
      return Promise.resolve();
    }
  })
});
複製程式碼

有了 prefetchData 函式之後,我們就可以在node做ssr的時候直接呼叫這個函式即可完成首屏資料的載入。

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[] = [];

  const dom = (
    <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
  };
}
複製程式碼

這裡我們多了兩個引數—— modelparams ,分別表示當前的 model 以及要傳入 prefetchData 函式的引數。

然後我們在處理一下 homeController 中呼叫 getPage 的地方就完成了:

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;
複製程式碼

所有工作準備就緒之後,再次開啟我們的網站,訪問 localhost:7999 ,發現已經可以順利的載入首屏資料了。

首屏資料載入優化

我們並不想只有經過node訪問的頁面才會拉取資料,經過前端路由切換的頁面也要載入首屏資料,只不過是在 componentDidMount 之後再載入而已,因此我們需要改造一下 demo 元件:

containers/demo.tsx

// ...
componentDidMount() {
    this.props.prefetchData();
}
// ...
複製程式碼

改造完之後,我們發現當首屏載入的是 /todolist 頁面的時候,前端切換到 /demo 頁面,過一會會成功觸發 prefetchData() 函式, count 變成了23。

但是當我們直接訪問 /demo 頁面的時候,卻發現經過的node的首屏資料載入之後, count 的初始值就是23,然後過了一會 prefetchData() 執行完之後 count 變成了36,這不符合我們的預期,因此首屏資料載入這裡還需要優化。

我們需要判斷哪個頁面進行了首屏資料載入,當該頁面已經進行了首屏資料載入之後, didmount 時便不再載入資料。

因此這裡我想了幾種辦法之後,最後選擇了記錄url的方式。

增加一個公共的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;
      return state;
    }
  }
});
複製程式碼

然後在 homeController 中初始化store的時候將url注入到 common 這個model裡面:

homeController.ts

const store = configureStore({
    common: {
        url: ctx.url
    },
    // ...
});
複製程式碼

這樣我們就可以通過common這個model中的url引數獲知到已經經過首屏資料載入的頁面了,然後對 containerconnect 部分改造一下,將 url 引數注入到 props 中:

containers/demo/index.tsx

const mapStateToProps = (store: any) => ({
  ...store.demo,
  url: store.common.url
});
複製程式碼

接下來在 utils 中寫一個拉取資料的函式,根據當前 locationprops.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;
複製程式碼

最後給每一個 container 加上 fetchData 函式即可:

componentDidMount() {
    utils.fetchData(this.props, this.props.prefetchData);
}
複製程式碼

至此,首次進行SPA+SSR+前後端同構的嘗試就到此完成了!

系列文章: