React最佳实践尝试(三)

栏目: 服务器 · 发布时间: 5年前

内容简介:配置完毕之后,接下来就开始开发一个简单的Demo页面吧~ 首先要定义好Demo的model模型:将定义好的然后编写container组件即可:

配置完毕之后,接下来就开始开发一个简单的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最佳实践尝试(三)

至此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最佳实践尝试(二)

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Little Schemer

The Little Schemer

[美] Daniel P. Friedman、[美] Matthias Felleisen / 卢俊祥 / 电子工业出版社 / 2017-7 / 65.00

《The Little Schemer:递归与函数式的奥妙》是一本久负盛名的经典之作,两位作者Daniel P. Friedman、Matthias Felleisen在程序语言界名声显赫。《The Little Schemer:递归与函数式的奥妙》介绍了Scheme的基本结构及其应用、Scheme的五法十诫、Continuation-Passing-Style、Partial Function、......一起来看看 《The Little Schemer》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换