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>
</>
);
}
}
複製程式碼
然後再訪問我們的頁面,就可以看到頂部有兩個常駐的路由供我們切換了
至此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
};
}
複製程式碼
這裡我們多了兩個引數——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+前後端同構的嘗試就到此完成了!
系列文章: