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> </> ); } } 複製代碼
而後再訪問咱們的頁面,就能夠看到頂部有兩個常駐的路由供咱們切換了
至此spa+ssr的構建就完成了!首屏數據即在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+先後端同構的嘗試就到此完成了!
系列文章: