9 主框架 & 列表页搭建

 

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


说明:

  1. 为啥要绑定 - 因为执行的时候,方法的调用者是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
    ...
}

说明:

  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

说明:

  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)


说明:

  1. 简单组件的定义方法
  2. 解构的方法,把Container的子组件都塞到Paper下
  3. children可能是个element数组或单个element元素
    1. 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

相关commit


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>
    )
  }
}
...

说明:

  1. 下面会实现的列表页Cell
  2. 假数据
  3. 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


说明:

  1. 下面实现的样式文件
  2. 由于需要配置style的组件,是一个简单组件,所以这样使用withStyle方法
  3. 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],
    },
  }
}


说明:

  1. 没啥好说明的...样式的定义比较简单

0x2 列表页数据处理

相关Commit

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


说明:

  1. 我们知道,服务端返回的model字段可能不全(当一些字段没有值的时候,key也不会返回)。所以这里的「topicSchema」类似于iOS中的「瘦model」,规定了返回对象的全量属性
  2. 简单封装的网络请求工具方法集
  3. Object.assign:是ES6新添加的接口,主要的用途是用来合并多个JavaScript的对象。
    1. 可以接收多个参数,第一个参数是目标对象,后面的都是源对象
    2. assign方法将多个原对象的属性和方法都合并到了目标对象上面
    3. 如果在合并过程中出现同名的属性(方法):后合并的属性(方法)会覆盖之前的同名属性(方法)
    4. 参考文章
  4. 把服务端返回的topic这个「瘦model」扩展为可以包含一些逻辑的「胖model」
  5. mobx提供的工具方法,可以把目标对象data的所有属性,都转化为@observable的属性,同时赋值给源对象Topic
    1. 作用类似Object.assign
    2. 文档
  6. 构造方法传参 & 指定默认值
    1. 文档
  7. 清空数据
  8. cnode提供的接口需要用到的参数,表示是否需要把md文件渲染为html格式后再返回

Store的配置

首先,把store配置到入口文件上,通过Provider组件让子组件都能拿到它
client/store/store.js


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(),
  }
}


说明:

  1. 这个方法在服务端渲染的时候会用到,为了保证服务端渲染和客户端使用的数据一致而实现的方法

client/app.js


...
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


说明:

  1. 拿到Provider中送进来的Store们,赋值给props
  2. 从props中拿需要的store,及其属性
  3. 指定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
      })
    ]
});

说明:

  1. 使用webpack的插件定义环境变量,使得客户端请求网络的baseUrl可外部配置
    1. DefinePlugin文档


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)
  })
}


说明:

  1. 从环境变量里拿baseUrl
  2. Object.keys
    1. keys文档
      1. 返回由指定对象可枚举属性的字符串列表
    2. reduce文档
      1. 对数组中的每个元素做操作

0x3 路由跳转 & Tab切换

相关Commit

如上,数据请求及使用已经实现了。

这一节,我们的目标是实现路由跳转 & 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

说明:

  1. tabs相关信息,拿到外面定义
  2. 根据url获取当前tab的值 - "all"这种
  3. 根据tab请求数据
  4. 路由跳转方法 - 把目标url push到history中,这样可以实现浏览器点击「返回」按钮的相关功能
    1. 会唤醒componentWillReceiveProps生命周期方法
  5. 接收到新的props,重新请求数据
  6. 解析url中,问号后面的部分
  7. 设置当前选中的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>
  )
}

说明:

  1. cx的用途:根据传入的对象,返回css样式中class的最终字符串
    1. 文档:https://www.npmjs.com/package/classnames
    2. 例子:classNames('foo', { bar: true }); // => 'foo bar'
    3. 如果传入的样式类名有两个,且两个样式属性有冲突,则后一个属性值覆盖前一个属性值