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'
- 如果传入的样式类名有两个,且两个样式属性有冲突,则后一个属性值覆盖前一个属性值