github地址:github.com/bbwlfx/ts-b…css
配置完毕以后,接下来就开始开发一个简单的Demo页面吧~ 首先要定义好Demo的model模型:html
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
目录下面。前端
export interface demoModalState {
count?: number;
outstr?: string;
}
复制代码
而后编写container组件便可:node
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
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
。webpack
前端组件写完了以后,别忘了对应的node中的路由和ssr的代码。git
import Router from "koa-router";
import homeController from "controllers/homeController";
const router = Router();
router.get("/demo", homeController.demo);
export default router;
复制代码
接下来就是业务处理的homeController
文件了:github
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页面来检查一下react-router
的spa效果,以及完善后续的首屏数据加载的问题。
依然是先定义model:
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页面:
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);
复制代码
而后别忘了给前端和后端路由注册组件:
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
是定义的常量,值为/
。
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
组件,加上两个公共路由便可:
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
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();
}
})
});
复制代码
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的时候直接调用这个函数便可完成首屏数据的加载。
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
的地方就完成了:
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
组件:
// ...
componentDidMount() {
this.props.prefetchData();
}
// ...
复制代码
改造完以后,咱们发现当首屏加载的是/todolist
页面的时候,前端切换到/demo
页面,过一会会成功触发prefetchData()
函数,count
变成了23。
可是当咱们直接访问/demo
页面的时候,却发现通过的node的首屏数据加载以后,count
的初始值就是23,而后过了一会prefetchData()
执行完以后count
变成了36,这不符合咱们的预期,所以首屏数据加载这里还须要优化。
咱们须要判断哪一个页面进行了首屏数据加载,当该页面已经进行了首屏数据加载以后,didmount
时便再也不加载数据。
所以这里我想了几种办法以后,最后选择了记录url的方式。
增长一个公共的model:common
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里面:
const store = configureStore({
common: {
url: ctx.url
},
// ...
});
复制代码
这样咱们就能够经过common这个model中的url参数获知到已经通过首屏数据加载的页面了,而后对container
的connect
部分改造一下,将url
参数注入到props
中:
const mapStateToProps = (store: any) => ({
...store.demo,
url: store.common.url
});
复制代码
接下来在utils
中写一个拉取数据的函数,根据当前location
和props.url
来判断是否须要拉取数据。
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+先后端同构的尝试就到此完成了!
系列文章: