github地址:github.com/bbwlfx/ts-b…
第一个页面
配置完毕之后,接下来就开始开发一个简单的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>
</>
);
}
}
复制代码
然后再访问我们的页面,就可以看到顶部有两个常驻的路由供我们切换了
首屏数据加载
首屏数据即在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
};
}
复制代码
这里我们多了两个参数——model
和params
,分别表示当前的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参数获知到已经经过首屏数据加载的页面了,然后对container
的connect
部分改造一下,将url
参数注入到props
中:
containers/demo/index.tsx
const mapStateToProps = (store: any) => ({
...store.demo,
url: store.common.url
});
复制代码
接下来在utils
中写一个拉取数据的函数,根据当前location
和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;
复制代码
最后给每一个container
加上fetchData
函数即可:
componentDidMount() {
utils.fetchData(this.props, this.props.prefetchData);
}
复制代码
至此,首次进行SPA+SSR+前后端同构的尝试就到此完成了!
系列文章: