9 主框架 & 列表页搭建
Wed, Oct 17, 2018
0x0 页面框架搭建
基本UI元素搭建
client/views/layout/app-bar.jsx
import React from 'react'
import AppBar from '@material-ui/core/AppBar'
import ToolBar from '@material-ui/core/Toolbar'
import Button from '@material-ui/core/Button'
import IconButton from '@material-ui/core/IconButton'
import HomeIcon from '@material-ui/icons/Home'
import Typography from '@material-ui/core/Typography'
class MainAppBar extends React.Component {
constructor() {
super()
this.onHomeIconClick = this.onHomeIconClick.bind(this) // #1
this.createButtonClick = this.createButtonClick.bind(this)
this.loginButtonClick = this.loginButtonClick.bind(this)
}
/* eslint-disable */
onHomeIconClick() {
}
createButtonClick() {
}
loginButtonClick() {
}
/* eslint-enable */
render() {
return (
<div>
<AppBar position="fixed">
<ToolBar>
<IconButton color="contrast" onClick={this.onHomeIconClick}>
<HomeIcon />
</IconButton>
<Typography type="title" color="inherit">
JNode
</Typography>
<Button raised color="accent" onClick={this.createButtonClick}>
新建话题
</Button>
<Button color="contrast" onClick={this.loginButtonClick}>
登录
</Button>
</ToolBar>
</AppBar>
</div>
)
}
}
export default MainAppBar
|
说明:
- 为啥要绑定 - 因为执行的时候,方法的调用者是window,没有当前语义中this的上下文了,所以需要绑定一下,把组件上下文传给它,让他永远使用这个组件实例上下文
client/views/App.jsx
import React from 'react'
import Routers from '../config/router'
import AppBar from './layout/app-bar'
export default class App extends React.Component {
componentDidMount() {
// placeholder
}
render() {
return [
<AppBar />,
<Routers key="routes" />,
]
}
} |
build/webpack.config.client.js
if (isDev) {
config.devtool = '#cheap-module-eval-source-map' // #1
...
} |
说明:
允许浏览器调试时,浏览jsx源码而非编译后的js
样式搭建
client/views/layout/app-bar.jsx
import { withStyles } from '@material-ui/core/styles'
...
const styles = {
root: {
width: '100%',
},
flex: {
flex: 1,
},
}
...
class MainAppBar extends React.Component {
...
render() {
const { classes } = this.props
return (
<div className={classes.root}>
<AppBar position="fixed">
<ToolBar>
...
<Typography type="title" color="inherit" className={classes.flex}>
JNode
</Typography>
...
</ToolBar>
</AppBar>
</div>
)
}
}
MainAppBar.propTypes = {
classes: PropTypes.object.isRequired,
}
export default withStyles(styles)(MainAppBar) // #1 |
说明:
- 需要export的时候,用withStyle方法,会使用styles做一个组件壳,套在要使用样式的组件外面
container搭建
我们搭建的页面简单,每个页面都是在页面正中的区域中,有一个区块,放列表或详情内容 - 这个区块可以先定义好,就是我们的Container
client/views/layout/container.jsx
import React from 'react'
import PropTypes from 'prop-types'
import { withStyles } from '@material-ui/core/styles'
import Paper from '@material-ui/core/Paper'
const styles = {
root: {
margin: 24,
marginTop: 80,
},
}
const Container = ({ classes, children }) => ( // #1
<Paper elevation={4} className={classes.root}>
{children} // #2
</Paper>
)
Container.propTypes = {
classes: PropTypes.object.isRequired,
children: PropTypes.oneOfType([ // #3
PropTypes.arrayOf(PropTypes.element),
PropTypes.element,
]),
}
export default withStyles(styles)(Container)
|
说明:
- 简单组件的定义方法
- 解构的方法,把Container的子组件都塞到Paper下
- children可能是个element数组或单个element元素
- element:能在React组件下做渲染的任何东西
client/views/topic-list/index.jsx
...
import Container from '../layout/container'
...
class TopicList extends React.Component {
...
render() {
return (
<Container>
...
</Container>
)
}
}
... |
本节遗留问题
发现使用本节说的样式定义方式,会使服务端渲染不生效。
查看了下服务端渲染结果,发现css生成了,但是组件的class名没有被赋值上。还需要再看下。
0x1 列表页UI搭建
我们需要实现两个东东:顶部的Tab导航条 & 列表的一个cell
client/views/topic-list/index.jsx
...
import Tabs from '@material-ui/core/Tabs'
import Tab from '@material-ui/core/Tab'
...
import Container from '../layout/container'
import TopicListItem from './list-item' // #1
...
class TopicList extends React.Component {
constructor() {
super()
this.changeTab = this.changeTab.bind(this)
this.listItemClick = this.listItemClick.bind(this)
this.state = {
tabIndex: 0,
}
}
...
changeTab(e, index) {
this.setState({
tabIndex: index,
})
}
listItemClick(e) {
console.log(this.state, e)
}
render() {
const {
tabIndex,
} = this.state
const topic = { // #2
title: 'This is title',
username: 'Azen',
replay_count: 20,
visit_count: 30,
create_at: '2018-10-10',
tab: 'share',
}
return (
<Container>
...
<Tabs value={tabIndex} onChange={this.changeTab}> // #3
<Tab label="全部" />
<Tab label="分享" />
<Tab label="工作" />
<Tab label="问答" />
<Tab label="精品" />
<Tab label="测试" />
</Tabs>
<TopicListItem onClick={this.listItemClick} topic={topic} />
</Container>
)
}
}
... |
说明:
- 下面会实现的列表页Cell
- 假数据
- value属性,指定当前选中的index
client/views/topic-list/list-item.jsx
import React from 'react'
import ListItem from '@material-ui/core/ListItem'
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
import ListItemText from '@material-ui/core/ListItemText'
// import Avatar from '@material-ui/core/Avatar'
import IconHome from '@material-ui/icons/Home'
import { withStyles } from '@material-ui/core/styles'
import PropTypes from 'prop-types'
import { topicPrimaryStyle, topicSecondaryStyle } from './styles' // #1
const Primary = ({ classes, topic }) => (
<div className={classes.root}>
<span className={classes.tab}>{topic.tab}</span>
<span className={classes.title}>{topic.title}</span>
</div>
)
const StyledPrimary = withStyles(topicPrimaryStyle)(Primary) // #2
Primary.propTypes = {
topic: PropTypes.object.isRequired,
classes: PropTypes.object.isRequired,
}
const Secondary = ({ classes, topic }) => (
<div className={classes.root}>
<span className={classes.userName}>{topic.username}</span>
<span className={classes.count}>
<span className={classes.accentColor}>{topic.replay_count}</span>
<span>/</span>
<span>{topic.visit_count}</span>
</span>
<span>
创建时间:
{topic.create_at}
</span>
</div>
)
Secondary.propTypes = {
topic: PropTypes.object.isRequired,
classes: PropTypes.object.isRequired,
}
const StyledSecondary = withStyles(topicSecondaryStyle)(Secondary)
const TopicListItem = ({ onClick, topic }) => {
return (
<ListItem>
<ListItemAvatar>
{/* <Avatar src={topic.image} /> */}
<IconHome />
</ListItemAvatar>
<ListItemText
primary={<StyledPrimary topic={topic} />} // #3
secondary={<StyledSecondary topic={topic} />}
/>
</ListItem>
)
}
TopicListItem.propTypes = {
topic: PropTypes.object.isRequired,
onClick: PropTypes.func.isRequired,
}
export default TopicListItem
|
说明:
- 下面实现的样式文件
- 由于需要配置style的组件,是一个简单组件,所以这样使用withStyle方法
- ListItemText组件,支持传入「上下两行」的组件样式
client/views/topic-list/styles.js
export const topicPrimaryStyle = (theme) => {
return {
root: {
display: 'flex',
alignItems: 'center',
},
title: {
color: '#555',
},
tab: {
backgroundColor: theme.palette.primary[500],
textAlign: 'center',
display: 'inline-block',
padding: '0 6px',
color: '#fff',
borderRadius: 3,
marginRight: 10,
fontSize: '12px',
},
}
}
export const topicSecondaryStyle = (theme) => {
return {
root: {
display: 'flex',
alignItems: 'center',
paddingTop: 3,
},
count: {
textAlign: 'center',
marginRight: 20,
},
userName: {
marginRight: 20,
color: '#9e9',
},
tab: {
backgroundColor: theme.palette.primary[500],
textAlign: 'center',
display: 'inline-block',
padding: '0 6px',
color: '#fff',
borderRadius: 3,
marginRight: 10,
fontSize: '12px',
},
accentColor: {
color: theme.palette.accent[300],
},
}
}
|
说明:
- 没啥好说明的...样式的定义比较简单
0x2 列表页数据处理
Store的定义
处理数据,首先要定义数据相关的Store。而我们使用的数据方案为mobx,这里需要用到mobx的相关工具。
可以料想,如果使用Redux的话,方法也是类似的,只不过使用Redux提供的相关工具就好
client/store/topic-store.js
import {
observable,
action,
extendObservable,
} from 'mobx'
import { topicSchema } from '../util/variable-define' // #1
import { get } from '../util/http' // #2
const createTopic = (topic) => {
return Object.assign({}, topicSchema, topic) // #3
}
class Topic { // #4
constructor(data) {
extendObservable(this, data) // #5
}
@observable syncing = false
}
class TopicStore {
@observable topics
@observable syncing
constructor({ syncing, topics } = { syncing: false, topics: [] }) { // #6
this.syncing = syncing
this.topics = topics.map(topic => new Topic(createTopic(topic)))
}
addTopic(topic) {
this.topics.push(new Topic(createTopic(topic)))
}
@action fetchTopics() {
return new Promise((resolve, reject) => {
this.syncing = true
this.topics = [] // #7
get('/topics', {
mdrender: false, // #8
}).then((resp) => {
if (resp.success) {
resp.data.forEach((topic) => {
this.addTopic(topic)
})
this.syncing = false
resolve()
} else {
reject()
}
this.syncing = false
}).catch((err) => {
reject(err)
this.syncing = false
})
})
}
}
export default TopicStore
|
说明:
- 我们知道,服务端返回的model字段可能不全(当一些字段没有值的时候,key也不会返回)。所以这里的「topicSchema」类似于iOS中的「瘦model」,规定了返回对象的全量属性
- 简单封装的网络请求工具方法集
- Object.assign:是ES6新添加的接口,主要的用途是用来合并多个JavaScript的对象。
- 可以接收多个参数,第一个参数是目标对象,后面的都是源对象
- assign方法将多个原对象的属性和方法都合并到了目标对象上面
- 如果在合并过程中出现同名的属性(方法):后合并的属性(方法)会覆盖之前的同名属性(方法)
- 参考文章
- 把服务端返回的topic这个「瘦model」扩展为可以包含一些逻辑的「胖model」
- mobx提供的工具方法,可以把目标对象data的所有属性,都转化为@observable的属性,同时赋值给源对象Topic
- 作用类似Object.assign
- 文档
- 构造方法传参 & 指定默认值
- 清空数据
- cnode提供的接口需要用到的参数,表示是否需要把md文件渲染为html格式后再返回
Store的配置
import AppStateClass from './app-state'
import TopicStore from './topic-store'
const AppState = AppStateClass
export { AppState, TopicStore }
export default {
AppState,
TopicStore,
}
export const createStoreMap = () => { // #1
return {
appState: new AppState(),
topicStore: new TopicStore(),
}
}
|
说明:
- 这个方法在服务端渲染的时候会用到,为了保证服务端渲染和客户端使用的数据一致而实现的方法
...
import { AppState, TopicStore } from './store/store'
...
const appState = new AppState(initialState.appState)
const topicStore = new TopicStore(initialState.topicStore)
...
const render = (Component) => {
...
ReactDOM.hydrate(
...
<Provider appState={appState} topicStore={topicStore}>
...
</Provider>
</AppContainer>, root,
)
}
... |
Store的使用
client/views/topic-list/index.jsx
...
import {
observer,
inject,
} from 'mobx-react'
...
import List from '@material-ui/core/List'
import CircularProgress from '@material-ui/core/CircularProgress'
...
@inject((stores) => { // #1
return {
appState: stores.appState,
topicStore: stores.topicStore,
}
}) @observer
class TopicList extends React.Component {
...
componentDidMount() {
this.props.topicStore.fetchTopics()
}
...
render() {
...
const {
topicStore,
} = this.props
const topicList = topicStore.topics
const syncingTopics = topicStore.syncing // #2
return (
<Container>
...
<List>
{
topicList.map(topic =>
<TopicListItem onClick={this.listItemClick} topic={topic} key={topic.id} />)
}
</List>
{
syncingTopics ? (
<div>
<CircularProgress color="accent" size={100} />
</div>
) : null
}
</Container>
)
}
}
TopicList.wrappedComponent.propTypes = { // #3
appState: PropTypes.object.isRequired,
topicStore: PropTypes.object.isRequired,
}
export default TopicList
|
说明:
- 拿到Provider中送进来的Store们,赋值给props
- 从props中拿需要的store,及其属性
- 指定TopicList的父组件的propTypes
其他工具小方法
build/webpack.config.server.js
...
const config = webpackMerge(baseConfig, {
...
plugins: [
new webpack.DefinePlugin({
'process.env.API_BASE': '"http://127.0.0.1:8787"' // #1
})
]
}); |
说明:
- 使用webpack的插件定义环境变量,使得客户端请求网络的baseUrl可外部配置
client/util/http.js
import axios from 'axios'
// /api/xxx 127.0.0.1
const baseUrl = process.env.API_BASE || '' // #1
const parseUrl = (url, params) => {
const str = Object.keys(params).reduce((result, key) => { // #2
return `${result}${key}=${params[key]}&`
}, '')
return `${baseUrl}/api${url}?${str.substr(0, str.length - 1)}`
}
export const get = (url, params) => {
return new Promise((resolve, reject) => {
axios.get(parseUrl(url, params))
.then((resp) => {
const { data } = resp
if (data && data.success === true) {
resolve(data)
} else {
reject(data)
}
}).catch(reject)
})
}
export const post = (url, params, reqData) => {
return new Promise((resolve, reject) => {
axios.post(parseUrl(url, params), reqData)
.then((resp) => {
const {
data,
} = resp
if (data && data.success === true) {
resolve(data)
} else {
reject(data)
}
}).catch(reject)
})
}
|
说明:
0x3 路由跳转 & Tab切换
如上,数据请求及使用已经实现了。
这一节,我们的目标是实现路由跳转 & Tab切换
切换tab之后,页面其实没变,只是页面上的数据重新请求了而已,我们需要实现「根据tab请求对应接口」功能
client/store/topic-store.js
...
class TopicStore {
...
@action fetchTopics(tab) {
return new Promise((resolve, reject) => {
...
get('/topics', {
mdrender: false,
tab,
})...
} |
client/views/topic-list/index.jsx
...
import queryString from 'query-string'
...
import { tabs } from '../../util/variable-define' // #1
...
class TopicList extends React.Component {
...
componentDidMount() {
const tab = this.getTab() // #2
this.props.topicStore.fetchTopics(tab) // #3
}
componentWillReceiveProps(nextProps) { // #5
if (nextProps.location.search !== this.props.location.search) {
this.props.topicStore.fetchTopics(this.getTab(nextProps.location.search))
}
}
getTab(search) {
const se = search || this.props.location.search
const query = queryString.parse(se)
return query.tab || 'all'
}
...
changeTab(e, value) {
this.props.history.push({ // #4
pathname: '/list',
search: `tab=${value}`,
})
}
...
render() {
const {
topicStore,
} = this.props
...
const query = queryString.parse(this.props.location.search) // #6
const { tab } = query
return (
<Container>
...
<Tabs value={tab} onChange={this.changeTab}> // #7
{
Object.keys(tabs).map((t) => {
return <Tab key={t} label={tabs[t]} value={t} />
})
}
</Tabs>
...
{
syncingTopics ? (
<div
style={{
display: 'flex',
justifyContent: 'space-around',
padding: '40px 0',
}}
>
<CircularProgress color="accent" size={100} />
</div>
) : null
}
</Container>
)
}
}
...
TopicList.propTypes = {
location: PropTypes.object.isRequired,
history: PropTypes.object,
}
export default TopicList |
说明:
- tabs相关信息,拿到外面定义
- 根据url获取当前tab的值 - "all"这种
- 根据tab请求数据
- 路由跳转方法 - 把目标url push到history中,这样可以实现浏览器点击「返回」按钮的相关功能
- 会唤醒componentWillReceiveProps生命周期方法
- 接收到新的props,重新请求数据
- 解析url中,问号后面的部分
- 设置当前选中的tab的value值
小功能点:修改「置顶」标签的样式
client/views/topic-list/list-item.jsx
...
import cx from 'classnames'
...
import { tabs } from '../../util/variable-define'
...
const Primary = ({ classes, topic }) => {
const classNames = cx({ // #1
[classes.tab]: true,
[classes.top]: topic.top,
})
return (
<span className={classes.root}>
<span className={classNames}>{topic.top ? '置顶' : tabs[topic.tab]}</span>
<span className={classes.title}>{topic.title}</span>
</span>
)
} |
说明:
- cx的用途:根据传入的对象,返回css样式中class的最终字符串
- 文档:https://www.npmjs.com/package/classnames
- 例子:; // => 'foo bar'
- 如果传入的样式类名有两个,且两个样式属性有冲突,则后一个属性值覆盖前一个属性值