简书项目实战一: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
- 安装
yarn add styled-components
- 在 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;
- 在 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 来写相关的样式。
-
在 style.js 里书写组件样式
import styled from "styled-components"; // 创建出的一个 div 组件,里边带有样式 export const HeaderWrapper = styled.div` height: 56px; background: red; `;
-
引入组件
// common/Header/index.js import React, { Component } from "react"; import { HeaderWrapper } from "./style"; export default class Header extends Component { render() { return <HeaderWrapper>header</HeaderWrapper>; } }
-
显示效果
组件能使后,就可以开始作业了。
-
详细代码
// 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 嵌入头部图标
-
登录 iconfont,然后点击 资源管理 -> 我的项目 -> 创建项目
-
直接搜索后,添加到购物车,然后添加到项目即可。
-
最后下载到本地。下载包里有 html 文件,里边讲述了怎么使用图标。
-
文件夹转移到 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;
-
将样式引入到根节点
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();
-
复制标签粘贴即可。
... <NavItem className="right"> <span className="iconfont"></span> </NavItem> <NavSearch></NavSearch> ...
样式再稍微修改一下后,效果如下:
4. 搜索框动画效果实现
点击搜索框,搜索框变大,同时搜索的 Icon 变色
-
在 Header 组件里添加 state
export default class Header extends Component { constructor(props) { super(props); this.state = { focused: false } } ...
-
根据 state 来赋予类名
<SearchWrapper> <NavSearch className={this.state.focused ? "focused" : ""} ></NavSearch> <span className={this.state.focused ? "focused iconfont" : "iconfont"} >  </span> </SearchWrapper>
-
添加样式
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; } } `
-
使用逻辑来控制样式来添加类名
<SearchWrapper> <NavSearch className={this.state.focused ? "focused" : ""} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} ></NavSearch> <span className={this.state.focused ? "focused iconfont" : "iconfont"} >  </span> </SearchWrapper>
// 记得构造器绑定 this !!! handleInputFocus() { this.setState({ focused: true, }); } handleInputBlur() { this.setState({ focused: false, }) }
-
添加动画样式包 react-transition-group
yarn add react-transition-group
-
使用 CSSTransition 包裹需要动画的标签
<CSSTransition in={this.state.focused} timeout={200} classNames="slide" > <NavSearch className={this.state.focused ? "focused" : ""} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} ></NavSearch> </CSSTransition>
-
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。
-
安装 redux 和 react-redux
yarn add redux yarn add react-redux
-
创建 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;
-
在 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;
-
光通过 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 方法。
-
-
将 Header 的 state 里的 focused 数据放在 reducer 里的 defaultState 里,放到 redux 仓库进行管理。
// store/reducer.js const defaultState = { focused: false, }; const reducer = (state = defaultState, action) => { return state; }; export default reducer;
-
把仓库里的 focused 拿出来,就可以通过
this.props.focused
来获取 focusedconst mapStateToProps = (state) => { return { focused: state.focused } }
-
点击输入框和不点击输入框的事件也要跟着重写,以前是通过 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"></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"}>  </span> </SearchWrapper> </Nav> <Addition> <Button className="writing"> <span className="iconfont"></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 组件下。
-
先在 Header 文件夹里新建 store 文件夹,然后新建 reducer.js 文件,将之前的和 Header 有关的 reducer 都迁移到这里。
-
此时原本的主 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 下)。
-
但是现在点击输入框触发使 focused 改变的事件,但是发现改变是改变了,搜索框的样式没有改。原因在于,Header 组件没有读取到 focused。
因为数据结构多了一层,因此 mapStateToProps 获取数据需要多加一层 header。
更改完后,点击输入框的动画就又回来了。拆分 reducer 后,可读性大幅提高。
-
优化:引入 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 里。
-
在 Header/store 文件夹里新建 actionCreator.js 文件
// Header/store export const searchFocus = () => ({ type: "search_focus", }); export const searchBlur = () => ({ type: "search_blur", });
-
在 Header/index.js 里完全导入后,使用即可
// Header/index.js import * as actionCreators from "./store/actionCreators" ...... const mapDispatchToProps = (dispatch) => { return { handleInputFocus() { dispatch(actionCreators.searchFocus()); }, handleInputBlur() { dispatch(actionCreators.searchBlur()); }, }; }; ......
-
将 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 就避免了不小心被操作改变的问题。
-
安装 immutable
yarn add immutable
-
引入方法并将 defaultState 转化成 immutable 对象
// reducer.js import { fromJS } from "immutable"; const defaultState = fromJS({ focused: false, });
-
此时,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 一般里边只能写对象)。
-
安装 redux-thunk
yarn add redux-thunk
-
在 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;
-
在 focused 的时候触发加载列表的函数。
redux-thunk 就是帮助做这个事情的,帮忙返回一个函数,而这个函数又可以干很多事情,这里就派发一个 Ajax 请求(需要安装 axios)。
// Header/index.js const mapDispatchToProps = (dispatch) => { return { handleInputFocus() { dispatch(actionCreators.getList()); dispatch(actionCreators.searchFocus()); }, handleInputBlur() { dispatch(actionCreators.searchBlur()); }, }; };
-
自己创建接口。
因为这里没有后端同学提供接口,所以前端需要自己提供假数据。在 public 文件夹里创建 json 文件,然后用 ajax 来访问即可。
react-create-app 底层是一个 node 服务器,如果找不到路由,就会去 public 文件夹里寻找是否有相关文件。
{ "success": true, "data": ["高考","区块链","三生三世","崔永元",...] }
-
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); }); }; };
-
reducer 里处理数据。
const reducer = (state = defaultState, action) => { switch (action.type) { ...... case constants.CHANGE_LIST: return state.set("list", action.data); default: return state; } };
-
经过了 reducer 处理后,已经能获取到 list 数据了。现在只要通过 mapStateToProps 获取即可。
// Header/index.js const mapStateToProps = (state) => { return { focused: state.getIn(["header", "focused"]), list: state.getIn(["header", "list"]), }; };
-
获取后,渲染的时候遍历即可。数组转化成 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; } }
-
显示效果:
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;
}
};
-
初始的时候,只显示第一页的内容
获取 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; } }
-
换一批现在有点小问题需要修改。
按照上面的代码逻辑,如果点击换一批,搜索框就失焦了,失焦的同时,热门搜索的显示是和 focused 绑定的,所以就消失了,那点了个寂寞。这是不符合逻辑的。
正常的效果应该是,热门搜索点击的时候,搜索框失焦,但是热门搜索不消失,得等到鼠标移出热门搜索区域后,热门搜索才消失。
通过上面的推断可以说明,热门搜索的显示效果不仅由 focused 变量决定,还通过 mouseenter 和 mouseleave 决定。
首先,在 defaultState 里添加属性
mouseIn: false
。然后,在 SearchInfo 里添加 onMouseEnter 事件,在鼠标进入热门搜索的时候,在 reducer 里将 mouseIn 改为 true。这些简单内容就不展示代码了。
同样的,需要 onMouseLeave 事件,将 mouseIn 改为 false。
有了 mouseIn 属性的辅助,现在热门搜索的显示与否由 focused 和 mouseIn 共同决定。
getListArea() { ...... if (focused || mouseIn) { return ...... }
这样搞完后就正常了。
-
实现换一批改变热门搜索推荐内容的功能。
// 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)); } }, // 其他的内容略
-
现在功能完成是完成了,但是命令行报错说没有唯一的 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> ); } }
-
最后一步优化一下,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. 换页旋转动画效果的实现
虽然换一批的功能已经实现,但是和简书实际的网站相比,还差了换一批的小图标和对应的旋转动画效果。现在就优化这一步。
-
在 iconfont 中寻找合适的 icon 后下载下来并替换。
-
换一批前面添加名称叫做 Spin 的 icon
// Header/index.js/getListArea() if (focused || mouseIn) { return ( <SearchInfo onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > <SearchInfoTitle> 热门搜索 <SearchInfoSwitch onClick={() => handleChangePage(page, totalPage)}> <span className="iconfont"></span> 换一批 </SearchInfoSwitch> </SearchInfoTitle> <SearchInfoList>{pageList}</SearchInfoList> </SearchInfo> ); } else { return null; }
可以看到是有效果了,但是 icon 显示有一点问题。
问题在于,之前布局搜索的 icon 的时候,用到了绝对定位,然后绝对定位样式写在了 iconfont 类名中,因此 Spin 受到波及。最简单的方法就是不要直接用 iconfont 类名来改样式,用 id 或者其他类来改,避免造成冲突。
解决冲突后,就恢复正常了。icon 后边样式稍微改改就行。
-
使用 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" >  </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 主要功能开发完成。