做者:Nicolas 本文原创,转载请注明做者及出处javascript
现在的 Web 前端已被 React、Vue 和 Angular 三分天下,一统江山十几年的 jQuery 显然已经很难知足如今的开发模式。那么,为何你们会以为 jQuery “过期了”呢?一来,文章《No JQuery! 原生 JavaScript 操做 DOM》就直截了当的告诉你,如今用原生 JavaScript 能够很是方便的操做 DOM 了。其次,jQuery 的便利性是创建在有一个基础 DOM 结构的前提下的,看上去是符合了样式、行为和结构分离,但其实 DOM 结构和 JavaScript 的代码逻辑是耦合的,你的开发思路会不断的在 DOM 结构和 JavaScript 之间来回切换。css
尽管如今的 jQuery 已再也不那么流行,但 jQuery 的设计思想仍是很是值得致敬和学习的,特别是 jQuery 的插件化。若是你们开发过 jQuery 插件的话,想必都会知道,一个插件要足够灵活,须要有细颗粒度的参数化设计。一个灵活好用的 React 组件跟 jQuery 插件同样,都离不开合理的属性化(props
)设计,但 React 组件的拆分和组合比起 jQuery 插件来讲仍是简单的使人发指。html
So! 接下来咱们就以万能的 TODO LIST 为例,一块儿来设计一款 React 的 TodoList
组件吧!前端
TODO LIST 的功能想必咱们应该都比较了解,也就是 TODO 的添加、删除、修改等等。自己的功能也比较简单,为了不示例的复杂度,显示不一样状态 TODO LIST 的导航(所有、已完成、未完成)的功能咱们就不展开了。java
先假设咱们已经拥有一个能够运行 React 项目的脚手架(ha~ 由于我不是来教你如何搭建脚手架的),而后项目的源码目录 src/
下多是这样的:react
.
├── components
├── containers
│ └── App
│ ├── app.scss
│ └── index.js
├── index.html
└── index.js
复制代码
咱们先来简单解释下这个目录设定。咱们看到根目录下的 index.js
文件是整个项目的入口模块,入口模块将会处理 DOM 的渲染和 React 组件的热更新(react-hot-loader)等设置。而后,index.html
是页面的 HTML 模版文件,这 2 个部分不是咱们此次关心的重点,咱们再也不展开讨论。jquery
入口模块 index.js
的代码大概是这样子的:webpack
// import reset css, base css...
import React from 'react';
import ReactDom from 'react-dom';
import { AppContainer } from 'react-hot-loader';
import App from 'containers/App';
const render = (Component) => {
ReactDom.render(
<AppContainer>
<Component />
</AppContainer>,
document.getElementById('app')
);
};
render(App);
if (module.hot) {
module.hot.accept('containers/App', () => {
let nextApp = require('containers/App').default;
render(nextApp);
});
}
复制代码
接下来看 containers/
目录,它将放置咱们的页面容器组件,业务逻辑、数据处理等会在这一层作处理,containers/App
将做为咱们的页面主容器组件。做为通用组件,咱们将它们放置于 components/
目录下。git
基本的目录结构看起来已经完成,接下来咱们实现下主容器组件 containers/App
。github
咱们先来看下主容器组件 containers/App/index.js
最初的代码实现:
import React, { Component } from 'react';
import styles from './app.scss';
class App extends Component {
constructor(props) {
super(props);
this.state = {
todos: []
};
}
render() {
return (
<div className={styles.container}>
<h2 className={styles.header}>Todo List Demo</h2>
<div className={styles.content}>
<header className={styles['todo-list-header']}>
<input
type="text"
className={styles.input}
ref={(input) => this.input = input}
/>
<button
className={styles.button}
onClick={() => this.handleAdd()}
>
Add Todo
</button>
</header>
<section className={styles['todo-list-content']}>
<ul className={styles['todo-list-items']}>
{this.state.todos.map((todo, i) => (
<li key={`${todo.text}-${i}`}>
<em
className={todo.completed ? styles.completed : ''}
onClick={() => this.handleStateChange(i)}
>
{todo.text}
</em>
<button
className={styles.button}
onClick={() => this.handleRemove(i)}
>
Remove
</button>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
handleAdd() {
...
}
handleRemove(index) {
...
}
handleStateChange(index) {
...
}
}
export default App;
复制代码
咱们能够像上面这样把全部的业务逻辑一股脑的塞进主容器中,但咱们要考虑到主容器随时会组装其余的组件进来,将各类逻辑堆放在一块儿,到时候这个组件就会变得无比庞大,直到“没法收拾”。因此,咱们得分离出一个独立的 TodoList
组件。
在 components/
目录下,咱们新建一个 TodoList
文件夹以及相关文件:
.
├── components
+│ └── TodoList
+│ ├── index.js
+│ └── todo-list.scss
├── containers
│ └── App
│ ├── app.scss
│ └── index.js
...
复制代码
而后咱们将 containers/App/index.js
下跟 TodoList
组件相关的功能抽离到 components/TodoList/index.js
中:
...
import styles from './todo-list.scss';
export default class TodoList extends Component {
...
render() {
return (
<div className={styles.container}>
- <header className={styles['todo-list-header']}>
+ <header className={styles.header}>
<input
type="text"
className={styles.input}
ref={(input) => this.input = input}
/>
<button
className={styles.button}
onClick={() => this.handleAdd()}
>
Add Todo
</button>
</header>
- <section className={styles['todo-list-content']}>
+ <section className={styles.content}>
- <ul className={styles['todo-list-items']}>
+ <ul className={styles.items}>
{this.state.todos.map((todo, i) => (
<li key={`${todo}-${i}`}>
<em
className={todo.completed ? styles.completed : ''}
onClick={() => this.handleStateChange(i)}
>
{todo.text}
</em>
<button
className={styles.button}
onClick={() => this.handleRemove(i)}
>
Remove
</button>
</li>
))}
</ul>
</section>
</div>
);
}
...
}
复制代码
有没有注意到上面 render
方法中的 className
,咱们省去了 todo-list*
前缀,因为咱们用的是 CSS MODULES,因此当咱们分离组件后,原先在主容器中定义的 todo-list*
前缀的 className
,能够很容易经过 webpack 的配置来实现:
...
module.exports = {
...
module: {
rules: [
{
test: /\.s?css/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
localIdentName: '[name]--[local]-[hash:base64:5]'
}
},
...
]
}
]
}
...
};
复制代码
咱们再来看下该组件的代码输出后的结果:
<div data-reactroot="" class="app--container-YwMsF">
...
<div class="todo-list--container-2PARV">
<header class="todo-list--header-3KDD3">
...
</header>
<section class="todo-list--content-3xwvR">
<ul class="todo-list--items-1SBi6">
...
</ul>
</section>
</div>
</div>
复制代码
从上面 webpack 的配置和输出的 HTML 中能够看到,className
的命名空间问题能够经过语义化 *.scss
文件名的方式来实现,好比 TodoList
的样式文件 todo-list.scss
。这样一来,省去了咱们定义组件 className
的命名空间带来的烦恼,从而只须要从组件内部的结构下手。
回到正题,咱们再来看下分离 TodoList
组件后的 containers/App/index.js
:
import TodoList from 'components/TodoList';
...
class App extends Component {
render() {
return (
<div className={styles.container}>
<h2 className={styles.header}>Todo List Demo</h2>
<div className={styles.content}>
<TodoList />
</div>
</div>
);
}
}
export default App;
复制代码
做为一个项目,当前的 TodoList
组件包含了太多的子元素,如:input、button 等。为了让组件“一次编写,随处使用”的原则,咱们能够进一步拆分 TodoList
组件以知足其余组件的使用。
可是,如何拆分组件才是最合理的呢?我以为这个问题没有最好的答案,但咱们能够从几个方面进行思考:可封装性、可重用性和灵活性。好比拿 h1
元素来说,你能够封装成一个 Title
组件,而后这样 <Title text={title} />
使用,又或者能够这样 <Title>{title}</Title>
来使用。但你有没有发现,这样实现的 Title
组件并无起到简化和封装的做用,反而增长了使用的复杂度,对于 HTML 来说,h1
自己也是一个组件,因此咱们拆分组件也是须要掌握一个度的。
好,咱们先拿 input 和 button 下手,在 components/
目录下新建 2 个 Button
和 Input
组件:
.
├── components
+│ ├── Button
+│ │ ├── button.scss
+│ │ └── index.js
+│ ├── Input
+│ │ ├── index.js
+│ │ └── input.scss
│ └── TodoList
│ ├── index.js
│ └── todo-list.scss
...
复制代码
Button/index.js
的代码:
...
export default class Button extends Component {
render() {
const { className, children, onClick } = this.props;
return (
<button
type="button"
className={cn(styles.normal, className)}
onClick={onClick}
>
{children}
</button>
);
}
}
复制代码
Input/index.js
的代码:
...
export default class Input extends Component {
render() {
const { className, value, inputRef } = this.props;
return (
<input
type="text"
className={cn(styles.normal, className)}
defaultValue={value}
ref={inputRef}
/>
);
}
}
复制代码
因为这 2 个组件自身不涉及任何业务逻辑,应该属于纯渲染组件(木偶组件),咱们可使用 React 轻量的无状态组件的方式来声明:
...
const Button = ({ className, children, onClick }) => (
<button
type="button"
className={cn(styles.normal, className)}
onClick={onClick}
>
{children}
</button>
);
复制代码
是否是以为酷炫不少!
另外,从 Input
组件的示例代码中看到,咱们使用了非受控组件,这里是为了下降示例代码的复杂度而特地为之,你们能够根据本身的实际状况来决定是否须要设计成受控组件。通常状况下,若是不须要获取实时输入值的话,我以为使用非受控组件应该够用了。
咱们再回到上面的 TodoList
组件,将以前分离的子组件 Button
,Input
组装进来。
...
import Button from 'components/Button';
import Input from 'components/Input';
...
export default class TodoList extends Component {
render() {
return (
<div className={styles.container}>
<header className={styles.header}>
<Input
className={styles.input}
inputRef={(input) => this.input = input}
/>
<Button onClick={() => this.handleAdd()}>
Add Todo
</Button>
</header>
...
</div>
);
}
}
...
复制代码
而后继续接着看 TodoList
的 items 部分,咱们注意到这部分包含了较多的渲染逻辑在 render
中,致使咱们须要浪费对这段代码与上下文之间会有过多的思考,因此,咱们何不把它抽离出去:
...
export default class TodoList extends Component {
render() {
return (
<div className={styles.container}>
...
<section className={styles.content}>
{this.renderItems()}
</section>
</div>
);
}
renderItems() {
return (
<ul className={styles.items}>
{this.state.todos.map((todo, i) => (
<li key={`${todo}-${i}`}>
...
</li>
))}
</ul>
);
}
...
}
复制代码
上面的代码看似下降了 render
的复杂度,但仍然没有让 TodoList
减小负担。既然咱们要把这部分逻辑分离出去,咱们何不建立一个 Todos
组件,把这部分逻辑拆分出去呢?so,咱们以“就近声明”的原则在 components/TodoList/
目录下建立一个子目录 components/TodoList/components/
来存放 TodoList
的子组件 。why?由于我以为 组件 Todos
跟 TodoList
有紧密的父子关系,且跟其余组件间也不太会有任何交互,也能够认为它是 TodoList
私有的。
而后咱们预览下如今的目录结构:
.
├── components
│ ...
│ └── TodoList
+│ ├── components
+│ │ └── Todos
+│ │ ├── index.js
+│ │ └── todos.scss
│ ├── index.js
│ └── todo-list.scss
复制代码
Todos/index.js
的代码:
...
const Todos = ({ data: todos, onStateChange, onRemove }) => (
<ul className={styles.items}>
{todos.map((todo, i) => (
<li key={`${todo}-${i}`}>
<em
className={todo.completed ? styles.completed : ''}
onClick={() => onStateChange(i)}
>
{todo.text}
</em>
<Button onClick={() => onRemove(i)}>
Remove
</Button>
</li>
))}
</ul>
);
...
复制代码
再看拆分后的 TodoList/index.js
:
render() {
return (
<div className={styles.container}>
...
<section className={styles.content}>
<Todos
data={this.state.todos}
onStateChange={(index) => this.handleStateChange(index)}
onRemove={(index) => this.handleRemove(index)}
/>
</section>
</div>
);
}
复制代码
到目前为止,大致上的功能已经搞定,子组件看上去拆分的也算合理,这样就能够很容易的加强某个子组件的功能了。就拿 Todos
来讲,在新增了一个 TODO 后,假如咱们并无完成这个 TODO,而咱们又但愿能够修改它的内容了。ha~不要着急,要不咱们再拆分下这个 Todos
,好比增长一个 Todo
组件:
.
├── components
│ ...
│ └── TodoList
│ ├── components
+│ │ ├── Todo
+│ │ │ ├── index.js
+│ │ │ └── todo.scss
│ │ └── Todos
│ │ ├── index.js
│ │ └── todos.scss
│ ├── index.js
│ └── todo-list.scss
复制代码
先看下 Todos
组件在抽离了 Todo
后的样子:
...
import Todo from '../Todo';
...
const Todos = ({ data: todos, onStateChange, onRemove }) => (
<ul className={styles.items}>
{todos.map((todo, i) => (
<li key={`${todo}-${i}`}>
<Todo
{...todo}
onClick={() => onStateChange(i)}
/>
<Button onClick={() => onRemove(i)}>
Remove
</Button>
</li>
))}
</ul>
);
export default Todos;
复制代码
咱们先不关心 Todo
内是何如实现的,就如咱们上面说到的那样,咱们须要对这个 Todo
增长一个可编辑的功能,从单纯的属性配置入手,咱们只须要给它增长一个 editable
的属性:
<Todo
{...todo}
+ editable={editable}
onClick={() => onStateChange(i)}
/>
复制代码
而后,咱们再思考下,在 Todo
组件的内部,咱们须要从新组织一些功能逻辑:
editable
属性来判断是否须要显示编辑按钮咱们先来实现下 Todo
的第一个功能点:
render() {
const { completed, text, editable, onClick } = this.props;
return (
<span className={styles.wrapper}>
<em
className={completed ? styles.completed : ''}
onClick={onClick}
>
{text}
</em>
{editable &&
<Button>
Edit
</Button>
}
</span>
);
}
复制代码
显然实现这一步彷佛没什么 luan 用,咱们还须要点击 Edit 按钮后能显示 Input
组件,使内容可修改。因此,简单的传递属性彷佛没法知足该组件的功能,咱们还须要一个内部状态来管理组件是否处于编辑中:
render() {
const { completed, text, editable, onStateChange } = this.props,
{ editing } = this.state;
return (
<span className={styles.wrapper}>
{editing ?
<Input
value={text}
className={styles.input}
inputRef={input => this.input = input}
/> :
<em
className={completed ? styles.completed : ''}
onClick={onStateChange}
>
{text}
</em>
}
{editable &&
<Button onClick={() => this.handleEdit()}>
{editing ? 'Update' : 'Edit'}
</Button>
}
</span>
);
}
复制代码
最后,Todo
组件在点击 Update 按钮后须要通知父组件更新数据:
handleEdit() {
const { text, onUpdate } = this.props;
let { editing } = this.state;
editing = !editing;
this.setState({ editing });
if (!editing && this.input.value !== text) {
onUpdate(this.input.value);
}
}
复制代码
须要注意的是,咱们传递的是更新后的内容,在数据没有任何变化的状况下通知父组件是毫无心义的。
咱们再回过头来修改下 Todos
组件对 Todo
的调用。先增长一个由 TodoList
组件传递下来的回调属性 onUpdate
,同时修改 onClick
为 onStateChange
,由于这时的 Todo
已不只仅只有单个点击事件了,须要定义不一样状态变动时的事件回调:
<Todo
{...todo}
editable={editable}
- onClick={() => onStateChange(i)}
+ onStateChange={() => onStateChange(i)}
+ onUpdate={(value) => onUpdate(i, value)}
/>
复制代码
而最终咱们又在 TodoList
组件中,增长 Todo
在数据更新后的业务逻辑。
TodoList
组件的 render
方法内的部分示例代码:
<Todos
editable
data={this.state.todos}
+ onUpdate={(index, value) => this.handleUpdate(index, value)}
onStateChange={(index) => this.handleStateChange(index)}
onRemove={(index) => this.handleRemove(index)}
/>
复制代码
TodoList
组件的 handleUpdate
方法的示例代码:
handleUpdate(index, value) {
let todos = [...this.state.todos];
const target = todos[index];
todos = [
...todos.slice(0, index),
{
text: value,
completed: target.completed
},
...todos.slice(index + 1)
];
this.setState({ todos });
}
复制代码
既然 TodoList
是一个组件,初始状态 this.state.todos
就有可能从外部传入。对于组件内部,咱们不该该过多的关心这些数据从何而来(可能经过父容器直接 Ajax 调用后返回的数据,或者 Redux、MobX 等状态管理器获取的数据),我以为组件的数据属性的设计能够从如下 3 个方面来考虑:
根据这几点,咱们能够对 TodoList
再作一番改造。
首先,对 TodoList
增长一个 todos
的默认数据属性,使父组件在没有传入有效属性值时也不会影响该组件的使用:
export default class TodoList extends Component {
constructor(props) {
super(props);
this.state = {
todos: props.todos
};
}
...
}
TodoList.defaultProps = {
todos: []
};
复制代码
而后,再新增一个内部方法 this.update
和一个组件的更新事件回调属性 onUpdate
,当数据状态更新时能够及时的通知父组件:
export default class TodoList extends Component {
...
handleAdd() {
...
this.update(todos);
}
handleUpdate(index, value) {
...
this.update(todos);
}
handleRemove(index) {
...
this.update(todos);
}
handleStateChange(index) {
...
this.update(todos);
}
update(todos) {
const { onUpdate } = this.props;
this.setState({ todos });
onUpdate && onUpdate(todos);
}
}
复制代码
这就完事儿了?No! No! No! 由于 this.state.todos
的初始状态是由外部 this.props
传入的,假如父组件从新更新了数据,会致使子组件的数据和父组件不一样步。那么,如何解决?
咱们回顾下 React 的生命周期,父组件传递到子组件的 props 的更新数据能够在 componentWillReceiveProps
中获取。因此咱们有必要在这里从新更新下 TodoList
的数据,哦!千万别忘了判断传入的 todos 和当前的数据是否一致,由于,当任何传入的 props 更新时都会致使 componentWillReceiveProps
的触发。
componentWillReceiveProps(nextProps) {
const nextTodos = nextProps.todos;
if (Array.isArray(nextTodos) && !_.isEqual(this.state.todos, nextTodos)) {
this.setState({ todos: nextTodos });
}
}
复制代码
注意代码中的 _.isEqual
,该方法是 Lodash 中很是实用的一个函数,我常常拿来在这种场景下使用。
因为本人对 React 的了解有限,以上示例中的方案可能不必定最合适,但你也看到了 TodoList
组件,既能够是包含多个不一样功能逻辑的大组件,也能够拆分为独立、灵巧的小组件,我以为咱们只须要掌握一个度。固然,如何设计取决于你本身的项目,正所谓:没有最好的,只有更合适的。仍是但愿本篇文章能给你带来些许的小收获。
iKcamp官网:www.ikcamp.com
访问官网更快阅读所有免费分享课程:《iKcamp出品|全网最新|微信小程序|基于最新版1.0开发者工具之初中级培训教程分享》。 包含:文章、视频、源代码
iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。
2019年,iKcamp原创新书《Koa与Node.js开发实战》已在京东、天猫、亚马逊、当当开售啦!