04.简书项目实战一:Header组件开发

简书项目实战一:Header组件开发

本文是 React 的仿写简书项目开发学习笔记。代码仓库:https://github.com/shijuhong/jianshu

1. 项目目录搭建,Styled-Components 与 Reset.css 的结合使用

create-react-app jianshu
cd jianshu
yarn start
1.1 Styled-Components 的使用

css 文件在全局是生效的,这个就会造成一个问题,因为有多个 css 文件,可能会造成相互冲突,因此使用一个第三方模块 styled-components

  1. 安装
yarn add styled-components
  1. 在 src 文件夹里创建全局样式文件 globalStyle.js。如果要写全局的样式,就得如下写法:
import { createGlobalStyle } from "styled-components";

const GlobalStyle = createGlobalStyle`
  body {
    margin: 0;
    padding: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
      'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
      sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
`;

export default GlobalStyle;
  1. 在 React 根节点那边添加 GlobalStyle。
// index.js
ReactDOM.render(
  <React.StrictMode>
    <GlobalStyle />
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

同级节点里边的样式就会生效。

其他的非全局样式的话,直接引入 js 文件就好。

1.2 Reset.css 的使用

每个浏览器都有默认的标签样式,且样子大多各不相同(margin,padding 等)。当我们写完一个网页的各个结构布局后,在写具体的样式之前,会适应兼容目前各个浏览器的版本差异,会对其进行样式的统一处理,即 reset 样式重置。

最经典的就是 Reset.css。这个放到刚才的全局样式里边就好。(还有 Normalize.css 等)

/* http://meyerweb.com/eric/tools/css/reset/ 
   v2.0 | 20110126
   License: none (public domain)
*/

html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed, 
figure, figcaption, footer, header, hgroup, 
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
	margin: 0;
	padding: 0;
	border: 0;
	font-size: 100%;
	font: inherit;
	vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure, 
footer, header, hgroup, menu, nav, section {
	display: block;
}
body {
	line-height: 1;
}
ol, ul {
	list-style: none;
}
blockquote, q {
	quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
	content: '';
	content: none;
}
table {
	border-collapse: collapse;
	border-spacing: 0;
}

2. 使用 styled-components 完成 Header 组件布局(1)

Header 是很多文件都需要使用的区块,所以放到公共文件夹 common 里面。同时创建 style.js 来写相关的样式。

  1. 在 style.js 里书写组件样式

    import styled from "styled-components";
    
    // 创建出的一个 div 组件,里边带有样式
    export const HeaderWrapper = styled.div`
      height: 56px;
      background: red;
    `;
    
  2. 引入组件

    // common/Header/index.js
    import React, { Component } from "react";
    import { HeaderWrapper } from "./style";
    
    export default class Header extends Component {
      render() {
        return <HeaderWrapper>header</HeaderWrapper>;
      }
    }
    
  3. 显示效果

    image-20220324222019853

    组件能使后,就可以开始作业了。

  4. 详细代码

    // common/Header/index.js
    import React, { Component } from "react";
    import {
      HeaderWrapper,
      Logo,
      Nav,
      NavItem,
      NavSearch,
      Addition,
      Button,
    } from "./style";
    
    export default class Header extends Component {
      render() {
        return (
          <HeaderWrapper>
            <Logo />
            <Nav>
              <NavItem className="left active">首页</NavItem>
              <NavItem className="left">下载App</NavItem>
              <NavItem className="right">登录</NavItem>
              <NavItem className="right">Aa</NavItem>
              <NavSearch></NavSearch>
            </Nav>
            <Addition>
              <Button className="writing">写文章</Button>
              <Button className="reg">注册</Button>
            </Addition>
          </HeaderWrapper>
        );
      }
    }
    
    // style.js
    import styled from "styled-components";
    // 引入图片,直接写在样式里会被当做字符串
    import logoPic from "../../static/logo.png";
    
    // 创建出的一个 div 组件,里边带有样式
    export const HeaderWrapper = styled.div`
      position: relative;
      height: 56px;
      border-bottom: 1px solid #f0f0f0;
    `;
    
    // attrs 里边用来添加标签属性
    export const Logo = styled.a.attrs({
      href: "/",
    })`
      position: absolute;
      top: 0;
      left: 0;
      display: block;
      width: 100px;
      height: 56px;
      background: url(${logoPic});
      /* 把图像图像扩展至最大尺寸,以使其宽度和高度完全适应内容区域。 */
      background-size: contain;
    `;
    
    export const Nav = styled.div`
      width: 960px;
      height: 100%;
      padding=right: 70px;
      box-sizing: border-box;
      margin: 0 auto;
    `;
    
    export const NavItem = styled.div`
      line-height: 56px;
      padding: 0 15px;
      font-size: 17px;
      color: #333;
      /* 当前模块的样式所决定的样式 */
      &.left {
        float: left;
      }
      &.right {
        float: right;
        color: #969696;
      }
      &.active {
        color: #ea6f5a;
      }
    `;
    
    export const NavSearch = styled.input.attrs({
      placeholder: "搜索",
    })`
      width: 160px;
      height: 38px;
      border: none;
      padding: 0 30px 0 20px;
      margin-top: 9px;
      margin-left: 20px;
      outline: none;
      border-radius: 19px;
      background: #eee;
      font-size: 14px;
      color: #666;
      &::placeholder {
        color: #999;
      }
    `;
    
    export const Addition = styled.div`
      position: absolute;
      right: 0;
      top: 0;
      height: 56px;
    `;
    
    export const Button = styled.div`
      float: right;
      margin-top: 9px;
      margin-right: 20px;
      padding: 0 20px;
      line-height: 38px;
      border-radius: 19px;
      border: 1px solid #ec6149;
      font-size: 15px;
      &.reg {
        color: #ec6149;
      }
      &.writing {
        color: #fff;
        background: #ec6149;
      }
    `;
    
    export const SearchWrapper = styled.div`
      position: relative;
      float: left;
      .iconfont {
        position: absolute;
        right: 5px;
        bottom: 5px;
        width: 30px;
        line-height: 30px;
        border-radius: 15px;
        text-align: center;
      }
    `
    

    最终效果:

3. 使用 iconfont 嵌入头部图标

  1. 登录 iconfont,然后点击 资源管理 -> 我的项目 -> 创建项目

  2. 直接搜索后,添加到购物车,然后添加到项目即可。

  3. 最后下载到本地。下载包里有 html 文件,里边讲述了怎么使用图标。

  4. 文件夹转移到 static 文件夹后,打开下载下来的 iconfont.css,并改一下路径(把本地路径改成在线路径)后,改成全局样式的 js 文件。

    import { createGlobalStyle } from "styled-components";
    
    const IconfontStyle = createGlobalStyle`
      @font-face {
        font-family: 'iconfont';  /* project id 3278405 */
        src: url('');
        src: url('?#iefix') format('embedded-opentype'),
        url('//at.alicdn.com/t/font_3278405_02z04q7k68b.woff2') format('woff2'),
        url('//at.alicdn.com/t/font_3278405_02z04q7k68b.woff') format('woff'),
        url('//at.alicdn.com/t/font_3278405_02z04q7k68b.ttf') format('truetype'),
        url('#iconfont') format('svg');
      }
    
      .iconfont {
        font-family: "iconfont" !important;
        font-size: 16px;
        font-style: normal;
        -webkit-font-smoothing: antialiased;
        -moz-osx-font-smoothing: grayscale;
      }
    `;
    
    export default IconfontStyle;
    
  5. 将样式引入到根节点

    import React from "react";
    import ReactDOM from "react-dom";
    import App from "./App";
    import reportWebVitals from "./reportWebVitals";
    import GlobalStyle from "./globalStyle";
    import IconfontStyle from "./static/iconfont/IconfontStyle"
    
    ReactDOM.render(
      <React.StrictMode>
        <GlobalStyle />
        <IconfontStyle />
        <App />
      </React.StrictMode>,
      document.getElementById("root")
    );
    
    // If you want to start measuring performance in your app, pass a function
    // to log results (for example: reportWebVitals(console.log))
    // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
    reportWebVitals();
    
  6. 复制标签粘贴即可。

    ...
              <NavItem className="right">
              <span className="iconfont">&#xe636;</span>
              </NavItem>
              <NavSearch></NavSearch>
    ...
    

    样式再稍微修改一下后,效果如下:

4. 搜索框动画效果实现

点击搜索框,搜索框变大,同时搜索的 Icon 变色

  1. 在 Header 组件里添加 state

    export default class Header extends Component {
      constructor(props) {
        super(props);
        this.state = {
          focused: false
        }
      }
    ...
    
  2. 根据 state 来赋予类名

              <SearchWrapper>
                <NavSearch
                  className={this.state.focused ? "focused" : ""}
                ></NavSearch>
                <span
                  className={this.state.focused ? "focused iconfont" : "iconfont"}
                >
                  &#xe6e4;
                </span>
              </SearchWrapper>
    
  3. 添加样式

    export const NavSearch = styled.input.attrs({
      placeholder: "搜索",
    })`
      ...
      &.focused {
        width: 200px;
      }
    `;
    
    export const SearchWrapper = styled.div`
      position: relative;
      float: left;
      .iconfont {
        ...
        &.focused {
          background: #777;
          color: #fff;
        }
      }
    `
    
  4. 使用逻辑来控制样式来添加类名

              <SearchWrapper>
                <NavSearch
                  className={this.state.focused ? "focused" : ""}
                  onFocus={this.handleInputFocus}
                  onBlur={this.handleInputBlur}
                ></NavSearch>
                <span
                  className={this.state.focused ? "focused iconfont" : "iconfont"}
                >
                  &#xe6e4;
                </span>
              </SearchWrapper>
    
      // 记得构造器绑定 this !!!  
      handleInputFocus() {
        this.setState({
          focused: true,
        });
      }
    
      handleInputBlur() {
        this.setState({
          focused: false,
        })
      }
    
  5. 添加动画样式包 react-transition-group

    yarn add react-transition-group
    
  6. 使用 CSSTransition 包裹需要动画的标签

              <CSSTransition
                  in={this.state.focused}
                  timeout={200}
                  classNames="slide"
                >
                  <NavSearch
                    className={this.state.focused ? "focused" : ""}
                    onFocus={this.handleInputFocus}
                    onBlur={this.handleInputBlur}
                  ></NavSearch>
                </CSSTransition>
    
  7. NavSearch 添加相关样式,使得动画可以进行

    export const NavSearch = styled.input.attrs({
      placeholder: "搜索",
    })`
      ...
        &.focused {
    		width: 240px;
    	}
        &.slide-enter {
    		transition: all .2s ease-out;
    	}
    	&.slide-enter-active {
    		width: 240px;
    	}
    	&.slide-exit {
    		transition: all .2s ease-out;
    	}
    	&.slide-exit-active {
    		width: 160px;
    	}
    `;
    

    这步结束后,没有差错的话,动画可以顺利运行。

5. 使用 React-Redux 进行应用数据的管理

既然使用 redux,数据就尽量都放在 redux 里,在后续的维护会带来非常大的帮助。

这里就以 Header 组件里的 focused 数据为例,将 focused 从 state 迁移到 redux。

  1. 安装 redux 和 react-redux

    yarn add redux
    yarn add react-redux
    
  2. 创建 store 文件夹,里边添加 store 和 reducer

    // store/index.js
    import { configureStore } from "@reduxjs/toolkit";
    import reducer from "./reducer";
    
    const store = configureStore({reducer});
    
    export default store;
    
    // store/reducer.js
    const defaultState = {};
    
    const reducer = (state = defaultState, action) => {
      return state;
    };
    
    export default reducer;
    
  3. 在 App 组件里的子组件用 Provider 组件包裹,Provider 内放置 store,表明被 Provider 包裹的组件都有能力获取到 store 里的数据。

    import { Provider } from "react-redux";
    import store from "./store";
    import Header from "./common/Header";
    
    function App() {
      return (
        <Provider store={store}>
          <Header />
        </Provider>
      );
    }
    
    export default App;
    
  4. 光通过 Provider 提供不行,Header 组件还需要做连接。这时需要用 connect 方法建立连接。

    // 导入
    import { connect } from "react-redux";
    
    // 末尾做连接
    const mapStateToProps = (state) => {
      return {
    
      }
    }
    
    const mapDispatchToProps = (dispatch) => {
      return {
    
      }
    }
    
    export default connect(mapStateToProps, mapDispatchToProps)(Header);
    

    连接的时候有两个关键参数:

    • mapStateToProps: 做连接的时候 store 如何映射到 prop 上。参数 state 代表 store 里的所有数据。

    • mapDispatchToProps:组件在和 store 做连接的时候,要发送改变 store 的指令都放在里边。参数 dispatch 代表 store.dispatch 方法。

  5. 将 Header 的 state 里的 focused 数据放在 reducer 里的 defaultState 里,放到 redux 仓库进行管理。

    // store/reducer.js
    const defaultState = {
      focused: false,
    };
    
    const reducer = (state = defaultState, action) => {
      return state;
    };
    
    export default reducer;
    
  6. 把仓库里的 focused 拿出来,就可以通过 this.props.focused 来获取 focused

    const mapStateToProps = (state) => {
      return {
        focused: state.focused
      }
    }
    
  7. 点击输入框和不点击输入框的事件也要跟着重写,以前是通过 setState 来修改 focused 值,现在要 dispatch action 来修改。这个时候就要在 mapDispatchToProps 来写 action。

    // Header/index.js
    const mapDispatchToProps = (dispatch) => {
      return {
        handleInputFocus() {
          const action = {
            type: "search_focus",
          };
          dispatch(action);
        },
        handleInputBlur() {
          const action = {
            type: "search_blur",
          };
          dispatch(action);
        },
      };
    };
    

    同时在 reducer 里接收 action,通过 action type 来处理并返回一个新 state。

    const defaultState = {
      focused: false,
    };
    
    const reducer = (state = defaultState, action) => {
      if (action.type === "search_focus") {
        // 使用对象展开运算符
        return {
          ...state,
          focused: true,
        };
      }
      if (action.type === "search_blur") {
        return {
          ...state,
          focused: false,
        }
      }
      return state;
    };
    
    export default reducer;
    

    注意:return 里边用上了对象展开运算符,拷贝一份旧的 state,因为只是想修改 state 里的 focused,而不是让 focused 对象直接顶替整个 state。最后返回的是 focused 被修改的 state。

    修改完后,因为 Header 组件里没有了 state 和方法,因此可以改成函数组件,提升了性能。

    import React from "react";
    import { connect } from "react-redux";
    import { CSSTransition } from "react-transition-group";
    import {
      HeaderWrapper,
      Logo,
      Nav,
      NavItem,
      NavSearch,
      Addition,
      Button,
      SearchWrapper,
    } from "./style";
    
    const Header = (props) => (
      <HeaderWrapper>
        <Logo />
        <Nav>
          <NavItem className="left active">首页</NavItem>
          <NavItem className="left">下载App</NavItem>
          <NavItem className="right">登录</NavItem>
          <NavItem className="right">
            <span className="iconfont">&#xe636;</span>
          </NavItem>
    
          <SearchWrapper>
            <CSSTransition in={props.focused} timeout={200} classNames="slide">
              <NavSearch
                className={props.focused ? "focused" : ""}
                onFocus={props.handleInputFocus}
                onBlur={props.handleInputBlur}
              ></NavSearch>
            </CSSTransition>
            <span className={props.focused ? "focused iconfont" : "iconfont"}>
              &#xe6e4;
            </span>
          </SearchWrapper>
        </Nav>
        <Addition>
          <Button className="writing">
            <span className="iconfont">&#xe600;</span>
            写文章
          </Button>
          <Button className="reg">注册</Button>
        </Addition>
      </HeaderWrapper>
    );
    
    const mapStateToProps = (state) => {
      return {
        focused: state.focused,
      };
    };
    
    const mapDispatchToProps = (dispatch) => {
      return {
        handleInputFocus() {
          const action = {
            type: "search_focus",
          };
          dispatch(action);
        },
        handleInputBlur() {
          const action = {
            type: "search_blur",
          };
          dispatch(action);
        },
      };
    };
    
    export default connect(mapStateToProps, mapDispatchToProps)(Header);
    

6. 使用 combineReducers 完成对数据的拆分管理

Redux Toolkit 在创建 store 的时候就封装了 Redux DevTools,如果还是用常规的 createStore,需要做一下操作才能使用 Redux DevTools 拓展。

import { createStore, applyMiddleware, compose } from 'redux';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(reducer, composeEnhancers());

export default store;

随着简书网站的开发,功能越来越多,store 里的数据越来越多,势必会造成 reducer 内容过多,因此拆分 reducer 是很有必要的。

就比如,刚才的 action 都是属于 Header 组件里的,因此 reducer 也归类于 Header 组件下。

  1. 先在 Header 文件夹里新建 store 文件夹,然后新建 reducer.js 文件,将之前的和 Header 有关的 reducer 都迁移到这里。

  2. 此时原本的主 reducer 就为空了,reducer 分散到不同的组件里,主 reducer 的任务就是将分散的 reducer 整合到一起。

    Redux 提供了 combineReducer 方法,用于整合分散的 reducers。

    // store/reducer.js
    import { combineReducers } from "redux";
    import headerReducer from "../common/Header/store/reducer";
    
    export default combineReducers({
      header: headerReducer,
    });
    

    代码改完可以发现 devTools 里的数据展示都发生了改变(focused 不再放在 root,而是放在 header 下)。

  3. 但是现在点击输入框触发使 focused 改变的事件,但是发现改变是改变了,搜索框的样式没有改。原因在于,Header 组件没有读取到 focused。

    因为数据结构多了一层,因此 mapStateToProps 获取数据需要多加一层 header。

    更改完后,点击输入框的动画就又回来了。拆分 reducer 后,可读性大幅提高。

  4. 优化:引入 headerReducer 的路径太长了,怎么办呢?

    这时候,在 Header/store 文件夹里,新建 index.js 文件,作为出口。

    // Header/store/index.js
    import reducer from "./reducer";
    
    export { reducer };
    

    此时,主体 reducer 的引入需要修改:

    // src/store/reducer.js
    import { combineReducers } from "redux";
    import { reducer as HeaderReducer } from "../common/Header/store";
    
    export default combineReducers({
      header: HeaderReducer,
    });
    

7. actionCreators 与 constants 的拆分

上边可以看到很明显的提升点,action types 应该用常量而非字符串,然后产生 action 的逻辑放在 actionCreator 里。

  1. 在 Header/store 文件夹里新建 actionCreator.js 文件

    // Header/store
    export const searchFocus = () => ({
      type: "search_focus",
    });
    
    export const searchBlur = () => ({
      type: "search_blur",
    });
    
  2. 在 Header/index.js 里完全导入后,使用即可

    // Header/index.js
    import * as actionCreators from "./store/actionCreators"
    ......
    const mapDispatchToProps = (dispatch) => {
      return {
        handleInputFocus() {
          dispatch(actionCreators.searchFocus());
        },
        handleInputBlur() {
          dispatch(actionCreators.searchBlur());
        },
      };
    };
    ......
    
  3. 将 action types 替换为常量

    在 Header/store 里创建 constants.js,后面懂得都懂。把 index.js 作为主出口暴露出去,然后其他的该怎么写就怎么写。

    // Header/store/index.js
    import reducer from "./reducer";
    import * as actionCreators from "./actionCreators"
    import * as constants from "./constants"
    
    export { reducer, actionCreators, constants };
    
    // Header/store/constants.js
    export const SEARCH_FOCUS = "header/SEARCH_FOCUS";
    export const SEARCH_BLUR = "header/SEARCH_BLUR";
    
    // Header/store/actionCreators.js
    import { constants } from ".";
    
    export const searchFocus = () => ({
      type: constants.SEARCH_FOCUS,
    });
    
    export const searchBlur = () => ({
      type: constants.SEARCH_BLUR,
    });
    
    // Header/store/reducers.js
    import { constants } from ".";
    
    const defaultState = {
      focused: false,
    };
    
    const reducer = (state = defaultState, action) => {
      switch (action.type) {
        case constants.SEARCH_FOCUS:
          return {
            ...state,
            focused: true,
          };
        case constants.SEARCH_BLUR:
          return {
            ...state,
            focused: false,
          };
        default:
          return {
            ...state,
          };
      }
    };
    
    export default reducer;
    

    改完后,因为 index 作为了暴露出口,因此 Header 组件的引入修改成按需引入即可。

    import { actionCreators } from "./store";
    ......
    const Header = ......
    

8. 使用 Immutable.js 来管理 store 中的数据

时时保持对 state 的不可变性的警惕, immutable.js 可以帮助生成一个 immutable 对象,这个对象不可改变。如果 state 是个 immutable 对象,那 state 就避免了不小心被操作改变的问题。

  1. 安装 immutable

    yarn add immutable
    
  2. 引入方法并将 defaultState 转化成 immutable 对象

    // reducer.js
    import { fromJS } from "immutable";
    
    const defaultState = fromJS({
      focused: false,
    });
    
  3. 此时,state.header 已经是 immutable 类型的数据,现在直接获取里边的数据是不允许的。需要用 get 方法来获取。

    获取 immutable 对象里的值
    • 错误写法

      const mapStateToProps = (state) => {
        return {
          focused: state.header.focused,
        };
      };
      
    • 正确写法

      const mapStateToProps = (state) => {
        return {
          focused: state.header.get("focused"),
        };
      };
      
    用 set返回设置属性后的新对象

    变成 immutable 对象后,reducer 也应该返回一个 immutable 对象而不是普通对象。

    immutable 对象的 set 方法,会结合之前 immutable 对象的值和设置的值,返回一个全新的对象。

    import { constants } from ".";
    import { fromJS } from "immutable";
    
    const defaultState = fromJS({
      focused: false,
    });
    
    const reducer = (state = defaultState, action) => {
      switch (action.type) {
        case constants.SEARCH_FOCUS:
          return state.set("focused", true);
        case constants.SEARCH_BLUR:
          return state.set("focused", false);
        default:
          return state;
      }
    };
    
    export default reducer;
    

    这些基本使用,并没有改变原本的 state,因此保证了不可变值。

9. 使用 redux-immutable 统一数据格式

有一个很难受的地方,在这里:

const mapStateToProps = (state) => {
    return {
        focused: state.header.focused,
    };
};

根 state 是一个普通对象,但是 state.header 是 immutable 对象,数据获取方式是不统一的。如果根 state 也是 immutable 对象就好了。

这个时候,就需要依赖第三方模块 redux-immutable。使用 redux-immutable 模块来接管 combineReducers 方法,就可以返回类型为 immutable 的根 state。

// src/store/reducer.js
import { combineReducers } from "redux-immutable";
import { reducer as HeaderReducer } from "../common/Header/store";

export default combineReducers({
    header: HeaderReducer,
});

改完后,获取方法也需要改变:

const mapStateToProps = (state) => {
    return {
        focused: state.get("header").get("focused"),
    };
};

改完后,能够正常运行了。

也可以用第二种写法:

const mapStateToProps = (state) => {
    return {
        focused: state.getIn(["header", "focused"]),
    };
};

10. 热门搜索样式布局

热门搜索效果:

显示效果的样式书写就不讲了,在交互上,点击搜索,热门搜索显示,取消聚焦,热门搜索框消失。

这个实现也简单,只要聚焦就渲染,不聚焦就不渲染。

const getListArea = (show) => {
  if (show) {
    return (
      <SearchInfo>
        <SearchInfoTitle>
          热门搜索
          <SearchInfoSwitch>换一批</SearchInfoSwitch>
        </SearchInfoTitle>
        <SearchInfoList>
          <SearchInfoItem>教育</SearchInfoItem>
          <SearchInfoItem>教育</SearchInfoItem>
          <SearchInfoItem>教育</SearchInfoItem>
          <SearchInfoItem>教育</SearchInfoItem>
          <SearchInfoItem>教育</SearchInfoItem>
          <SearchInfoItem>教育</SearchInfoItem>
        </SearchInfoList>
      </SearchInfo>
    );
  } else {
    return null;
  }
};

把这函数放在函数组件里就行:

      <SearchWrapper>
        ......
        {getListArea(props.focused)}
      </SearchWrapper>

11. Ajax 获取推荐数据

现在需要实现换一批功能来更新一批热门搜索的内容。这些内容都是通过 Ajax 来获取的。

因此列表内容也需要 放到 store 里。当 focused 为 true 的时候,就通过 Ajax 来加载 list。

Ajax 是异步操作,这个项目用 redux-thunk 中间件,在 action 里写函数(否则 action 一般里边只能写对象)。

  1. 安装 redux-thunk

    yarn add redux-thunk
    
  2. 在 configureStore 里配置 thunk

    import { configureStore } from "@reduxjs/toolkit";
    import reducer from "./reducer";
    import thunk from "redux-thunk";
    
    const store = configureStore({
      reducer,
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware({
          serializableCheck: false,
        }).concat(thunk),
    });
    
    export default store;
    
  3. 在 focused 的时候触发加载列表的函数。

    redux-thunk 就是帮助做这个事情的,帮忙返回一个函数,而这个函数又可以干很多事情,这里就派发一个 Ajax 请求(需要安装 axios)。

    // Header/index.js
    const mapDispatchToProps = (dispatch) => {
      return {
        handleInputFocus() {
          dispatch(actionCreators.getList());
          dispatch(actionCreators.searchFocus());
        },
        handleInputBlur() {
          dispatch(actionCreators.searchBlur());
        },
      };
    };
    
  4. 自己创建接口。

    因为这里没有后端同学提供接口,所以前端需要自己提供假数据。在 public 文件夹里创建 json 文件,然后用 ajax 来访问即可。

    react-create-app 底层是一个 node 服务器,如果找不到路由,就会去 public 文件夹里寻找是否有相关文件。

    {
    	"success": true,
    	"data": ["高考","区块链","三生三世","崔永元",...]
    }
    
  5. axios 返回的结果派发给 store。

    注意:axios 获取得来的数据是普通 js 对象,而 reducer 里的 list 是 immutable 对象。因此获得的数据需要先转换成 immutable 对象,避免 list 这个 immutable 对象被覆盖为普通对象。

    // actionCreators.js
    ......
    const changeList = (data) => ({
      type: constants.CHANGE_LIST,
      data: fromJS(data),
    });
    
    export const getList = () => {
      return async (dispatch) => {
        axios
          .get("/api/headerList.json")
          .then((res) => {
            const data = res.data;
            dispatch(changeList(data.data));
          })
          .catch((err) => {
            console.log(err);
          });
      };
    };
    
  6. reducer 里处理数据。

    const reducer = (state = defaultState, action) => {
      switch (action.type) {
        ......
        case constants.CHANGE_LIST:
          return state.set("list", action.data);
        default:
          return state;
      }
    };
    
  7. 经过了 reducer 处理后,已经能获取到 list 数据了。现在只要通过 mapStateToProps 获取即可。

    // Header/index.js
    const mapStateToProps = (state) => {
      return {
        focused: state.getIn(["header", "focused"]),
        list: state.getIn(["header", "list"]),
      };
    };
    
  8. 获取后,渲染的时候遍历即可。数组转化成 immutable 对象后,里边也有 map 方法,放心使用即可。

      getListArea() {
        const { focused, list } = this.props;
        if (focused) {
          return (
            <SearchInfo>
              <SearchInfoTitle>
                热门搜索
                <SearchInfoSwitch>换一批</SearchInfoSwitch>
              </SearchInfoTitle>
              <SearchInfoList>
                {list.map((item) => (
                  <SearchInfoItem key={item}>{item}</SearchInfoItem>
                ))}
              </SearchInfoList>
            </SearchInfo>
          );
        } else {
          return null;
        }
      }
    
  9. 显示效果:

12. 热门搜索换页功能实现

上面的显示效果我没有全截图,但是代码一看就知道,所有的列表结果都显示出来了。

现在需要做的是,只显示十条热门搜索。现在定义页数和总页数,换一批后增加页数即可。

// Header/store/reducer.js
const defaultState = fromJS({
  focused: false,
  list: [],
  page: 0,
  totalPage: 1,
});

同时,在 actionCreators 里,返回数据的同时,计算出页码的总页数。

// Header/store/reducer.js
const changeList = (data) => ({
  type: constants.CHANGE_LIST,
  data: fromJS(data),
  totalPage: Math.ceil(data.length / 10),
});

reducer 也做相应的赋值。

const reducer = (state = defaultState, action) => {
  switch (action.type) {
    ......
    case constants.CHANGE_LIST:
      return state.set("list", action.data).set("totalPage", action.totalPage);
    default:
      return state;
  }
};
  1. 初始的时候,只显示第一页的内容

    获取 store 里的 page 后,遍历 push 进要展示的数组即可。(注意:list 是 immutable 对象,获取里边的值不能直接用 index)

    // Header/index.js
      getListArea() {
        const { focused, list, page } = this.props;
        const pageList = [];
    
        for (let i = (page - 1) * 10; i < page * 10; i++) {
          pageList.push(
            <SearchInfoItem key={list.get(i)}>{list.get(i)}</SearchInfoItem>
          );
        }
    
        if (focused) {
          return (
            <SearchInfo>
              <SearchInfoTitle>
                热门搜索
                <SearchInfoSwitch>换一批</SearchInfoSwitch>
              </SearchInfoTitle>
              <SearchInfoList>{pageList}</SearchInfoList>
            </SearchInfo>
          );
        } else {
          return null;
        }
      }
    
  2. 换一批现在有点小问题需要修改。

    按照上面的代码逻辑,如果点击换一批,搜索框就失焦了,失焦的同时,热门搜索的显示是和 focused 绑定的,所以就消失了,那点了个寂寞。这是不符合逻辑的。

    正常的效果应该是,热门搜索点击的时候,搜索框失焦,但是热门搜索不消失,得等到鼠标移出热门搜索区域后,热门搜索才消失。

    通过上面的推断可以说明,热门搜索的显示效果不仅由 focused 变量决定,还通过 mouseenter 和 mouseleave 决定。

    首先,在 defaultState 里添加属性 mouseIn: false

    然后,在 SearchInfo 里添加 onMouseEnter 事件,在鼠标进入热门搜索的时候,在 reducer 里将 mouseIn 改为 true。这些简单内容就不展示代码了。

    同样的,需要 onMouseLeave 事件,将 mouseIn 改为 false。

    有了 mouseIn 属性的辅助,现在热门搜索的显示与否由 focused 和 mouseIn 共同决定。

      getListArea() {
        ......
        if (focused || mouseIn) {
          return ......
      }
    

    这样搞完后就正常了。

  3. 实现换一批改变热门搜索推荐内容的功能。

    // Header/index.js
    // getListArea()
                <SearchInfoSwitch onClick={() => handleChangePage(page, totalPage)}>
                  换一批
                </SearchInfoSwitch>
    // mapDispatchToProps
    // 判断当前页码,如果页码到头了,就从头开始
        handleChangePage(page, totalPage) {
          if (page < totalPage) {
            dispatch(actionCreators.changePage(page + 1));
          } else {
            dispatch(actionCreators.changePage(1));
          }
        },
    // 其他的内容略
    
  4. 现在功能完成是完成了,但是命令行报错说没有唯一的 key 值。

    这个问题藏的很深,原因在于,ajax 请求是异步的,Header 组件在 ajax 加载列表之前就执行了,造成产生了十个 key 值为 undefined 的 SearchInfoItem。

    // getListArea()
    // ajax 还未加载就执行,当然是加载不出数据的
        const pageList = [];
    
        for (let i = (page - 1) * 10; i < page * 10; i++) {
          pageList.push(
            <SearchInfoItem key={list.get(i)}>{list.get(i)}</SearchInfoItem>
          );
        }
    

    修改方法:ajax 获取数据后渲染才合适,多加一层判断即可。现在,就没有啥问题了。

    // store 里的 list 是否加载完成
        if (list.size !== 0) {
          for (let i = (page - 1) * 10; i < page * 10; i++) {
            pageList.push(
              <SearchInfoItem key={list.get(i)}>{list.get(i)}</SearchInfoItem>
            );
          }
        }
    
  5. 最后一步优化一下,reducer 里,如果要一次性修改多个参数,使用连续的 set 虽然也可以,但是因为多返回了一次 immutable 对象,因此不美观的同时,性能较低。

    return state.set("list", action.data).set("totalPage", action.totalPage);
    

    使用 merge 方法,一次性修改并返回 immutable 对象,是更优的写法:

    return state.merge({
      list: action.data,
      totalPage: action.totalPage,
    });
    

13. 换页旋转动画效果的实现

虽然换一批的功能已经实现,但是和简书实际的网站相比,还差了换一批的小图标和对应的旋转动画效果。现在就优化这一步。

  1. 在 iconfont 中寻找合适的 icon 后下载下来并替换。

  2. 换一批前面添加名称叫做 Spin 的 icon

    // Header/index.js/getListArea()
        if (focused || mouseIn) {
          return (
            <SearchInfo
              onMouseEnter={handleMouseEnter}
              onMouseLeave={handleMouseLeave}
            >
              <SearchInfoTitle>
                热门搜索
                <SearchInfoSwitch onClick={() => handleChangePage(page, totalPage)}>
                <span className="iconfont">&#xe851;</span>
                  换一批
                </SearchInfoSwitch>
              </SearchInfoTitle>
              <SearchInfoList>{pageList}</SearchInfoList>
            </SearchInfo>
          );
        } else {
          return null;
        }
    

    可以看到是有效果了,但是 icon 显示有一点问题。

    问题在于,之前布局搜索的 icon 的时候,用到了绝对定位,然后绝对定位样式写在了 iconfont 类名中,因此 Spin 受到波及。最简单的方法就是不要直接用 iconfont 类名来改样式,用 id 或者其他类来改,避免造成冲突。

    解决冲突后,就恢复正常了。icon 后边样式稍微改改就行。

  3. 使用 ref 控制 style 来实现旋转

    先写样式:

    export const SearchInfoSwitch = styled.span`
      float: right;
      font-size: 13px;
      .spin {
        display: block;
        float: left;
        font-size: 12px;
        margin-right: 2px;
        transition: all 0.2s ease-in;
        transform-origin: center center;
      }
    `;
    

    后给 icon 添加 ref 用来获取 dom 节点,然后触发点击事件的时候,修改 dom 节点的样式。

              <SearchInfoTitle>
                热门搜索
                <SearchInfoSwitch
                  onClick={() => handleChangePage(page, totalPage, this.spinIcon)}
                >
                  <span
                    ref={(icon) => {
                      this.spinIcon = icon;
                    }}
                    className="iconfont spin"
                  >
                    &#xe851;
                  </span>
                  换一批
                </SearchInfoSwitch>
              </SearchInfoTitle>
    

    修改样式的代码:

        handleChangePage(page, totalPage, spinIcon) {
          // 每次获取上一次的旋转角度
          let originAngle = spinIcon.style.transform.replace(/[^0-9]/gi, "");
          // 初始的时候,origin 没有值
          if (originAngle) {
            originAngle = parseInt(originAngle);
          } else {
            originAngle = 0;
          }
          spinIcon.style.transform = `rotate(${originAngle + 360}deg)`;
          // 如果此时在最后一页了,页数返回到第一页
          if (page < totalPage) {
            dispatch(actionCreators.changePage(page + 1));
          } else {
            dispatch(actionCreators.changePage(1));
          }
        },
    
    正则表达式规则:

    g:表示全局(global)模式,即模式将被应用于所有字符串,而非在发现第一个匹配项时立即
    停止;

    i:表示不区分大小写(case-insensitive)模式,即在确定匹配项时忽略模式与字符串的大小写;

    m:表示多行(multiline)模式,即在到达一行文本末尾时还会继续查找下一行中是否存在与模
    式匹配的项。

14. 避免无意义的请求发送,提升组件性能

每次点击一次搜索框,热门搜索的内容都会请求一次,但是热门搜索的内容只需要加载一次就行,后面的加载都是无意义的了。

解决方法:在 onFocus 事件触发的时候,将 list 传入 加载 list 的方法。判断 list 的元素数量,如果数量大于零,才触发获取列表数据的 action。

// Header Component
<NavSearch
    className={focused ? "focused" : ""}
    onFocus={() => handleInputFocus(list)}
    onBlur={handleInputBlur}
></NavSearch>
// mapDispatchToProps
    handleInputFocus(list) {
      list.size === 0 && dispatch(actionCreators.getList());
      dispatch(actionCreators.searchFocus());
    },

这样处理后,axios 只发送一次请求。

至此,Header 主要功能开发完成。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值