React最佳實踐嘗試(三)

github地址:github.com/bbwlfx/ts-b…css

第一個頁面

配置完畢以後,接下來就開始開發一個簡單的Demo頁面吧~ 首先要定義好Demo的model模型:html

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組件便可:node

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

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

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是定義的常量,值爲/demowebpack

前端組件寫完了以後,別忘了對應的node中的路由和ssr的代碼。git

/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文件了:github

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頁面大功告成!web

接下來啓動打包以後訪問頁面便可npm

$ 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> </> ); } } 複製代碼

而後再訪問咱們的頁面,就能夠看到頂部有兩個常駐的路由供咱們切換了

至此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+先後端同構的嘗試就到此完成了!

系列文章:

  1. React最佳實踐嘗試(一)技術選型
  2. React最佳實踐嘗試(二)
相關文章
相關標籤/搜索