在使用react编写前端代码时,为了代码的复用,我们用到了 `组件` 这一概念,这篇文章来探讨一下React.js 是怎么解决前端页面组件化,以及前端页面的组件化需要解决什么样的问题。
1、一个开关按钮
我们从一个简单的开关按钮开始吧,下面的代码中我们实现一个开关按钮,点击开关会切换 on/off
,其中<script>
标签中就是控制点击按钮切换状态的逻辑。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class='wrapper'>
<button class='switch-btn'>
<span class='switch-text'>开</span>
</button>
</div>
<script>
const button = document.querySelector('.switch-btn')
const text = button.querySelector('.switch-text')
let on = false
button.addEventListener('click', () => {
on = !on
if (on) {
text.innerHTML = 'on'
} else {
text.innerHTML = 'off'
}
}, false)
</script>
</body>
</html>
复制代码
这时候,另一个页面也需要这个开关按钮,或者你希望将这个按钮分享给其他其他前端同学使用,这时候问题来了,难道把把整个 button
代码和所有 JavaScript 代码复制过去吗?显然这种方式没有任何的复用性。
2、代码复用
我们修改一下上面的代码,让我们的开关按钮具备一定的复用性吧!我们先写一个类,这个类有 render
方法,调用 render()
直接返回一个表示 HTML
结构的字符串。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class='wrapper'>
</div>
<script>
class SwitchButton {
render () {
return `
<button class='switch-btn'>
<span class='switch-text'>off</span>
</button>
`
}
}
const wrapper = document.querySelector('.wrapper')
const switchButton1 = new SwitchButton()
wrapper.innerHTML = switchButton1.render()
const switchButton2 = new SwitchButton()
wrapper.innerHTML += switchButton2.render()
</script>
</body>
</html>
复制代码
这里非常暴力地使用了 innerHTML
,把两个按钮插入了 wrapper
当中,同时点击按钮也没有任何反应,这是因为我们还没有给组件添加事件处理的js代码。虽然这种实现目前还没有满足需求,但我们还是勉强了实现了结构的复用,我们后面再来优化它。
2.1 简单的组件化
我们现在需要给SwitchButton
添加点击事件,但是这玩意儿压根是在字符串里,怎么能在字符串里添加事件呢?DOM事件的API只有DOM结构才能使用呀?也就是说,我们需要DOM结构,更准确的说,我们需要这个开关按钮的HTML字符串表示的 DOM 结构。假设我们现在有一个函数 strToDOM
,你往这个函数传入HTML字符串,但是它会把相应的 DOM 元素返回给你,这个问题不就可以解决啦?
const strToDOM = (domString) => {
const div = document.createElement('div')
div.innerHTML = domString
return div
}
复制代码
现在拿这个函数来改造下我们的SwitchButton吧!
class SwitchButton {
render () {
this.el = strToDOM(`
<button class='switch-btn'>
<span class='switch-text'>off</span>
</button>
`)
this.el.addEventListener('click', () => console.log('click'), false)
return this.el
}
}
复制代码
现在 render()
返回的不是一个 html 字符串了,而是一个由这个 html 字符串所生成的 DOM。在返回 DOM 元素之前会先给这个 DOM 元素上添加事件再返回,因为现在 render
返回的是 DOM 元素,所以不能用 innerHTML
暴力地插入 wrapper
。而是要想下面代码那样,用 DOM API 插进去。
const wrapper = document.querySelector('.wrapper')
const switchButton1 = new SwitchButton()
wrapper.appendChild(switchButton1.render())
const switchButton2 = new SwitchButton()
wrapper.appendChild(switchButton2.render())
复制代码
此时,点击按钮会触发 () => console.log('click')
回调,看向控制台,这时候会输出'click'字符串。这不是我们想要的,我们想要的是切换按钮上 on/off
的回调。接下来优化下 SwitchButton
的类定义来完成这个事情吧!
class SwitchButton {
constructor () {
this.state = { on: false }
}
changeText () {
const text = this.el.querySelector('.switch-text')
this.state.on = !this.state.on
text.innerHTML = this.state.on ? 'on' : 'off'
}
render () {
this.el = strToDOM(`
<button class='switch-btn'>
<span class='switch-text'>off</span>
</button>
`)
this.el.addEventListener('click', this.changeText.bind(this), false)
return this.el
}
}
复制代码
上面的代码并不复杂,我们做了两件事情:1、添加一个变量 on
来控制开关的状态;2、自定义一个方法changeText
作为按钮click
事件的回调,使得按钮能根据点击事件切换状态,这时候点击按钮就能成功地切换 on/off
状态了。
现在这个组件的可复用性已经很不错了,只要实例化一下然后插入到 DOM 里面去就好了,但是这个组件真的完美了吗?
3、优化 DOM 操作
看看上一节我们的代码,仔细留意一下 changeText
方法,这个函数包含了 DOM 操作,例如 this.el.querySelector()
, text.innerHTML =
这些操作,现在看起来比较简单,那是因为现在只有 on
一个状态。由于数据状态改变会导致需要我们去更新页面的内容,所以假想一下,如果你的组件依赖了很多状态,那么你的组件基本全部都是DOM操作了。
一个组件的显示形态由多个状态决定的情况非常常见。代码中混杂着对DOM的操作其实是一种不好的实践,手动管理数据和 DOM 之间的关系会导致代码可维护性变差、容易出错。所以我们的例子这里还有优化的空间:如何尽量减少这种手动 DOM 操作?
3.1 状态改变 ➞ 构建新的DOM元素更新页面
转换一下思路:一旦状态发生改变,就重新调用 render
方法,构建一个新的 DOM 元素,将状态的变换放到 render
方法中, render
根据不同的 state
渲染出不同的视图 view
。你可以在 render
方法里面使用最新的 this.state
来构造不同 HTML 结构的字符串,并且通过这个字符串构造不同的 DOM 元素。页面就更新了!而不是通过DOM的API触发组件的组件更新。听起来有点抽象,看下代码你就明白了!
class SwitchButton {
constructor () {
this.state = { on: false }
}
setState (state) {
this.state = state
this.el = this.render()
}
changeText () {
this.setState({
on: !this.state.on
})
}
render () {
this.el = strToDOM(`
<button class='switch-btn'>
<span class='switch-text'>${this.state.on ? 'on' : 'off'}</span>
</button>
`)
this.el.addEventListener('click', this.changeText.bind(this), false)
return this.el
}
}
复制代码
其实只是改了几个小地方:
render
函数里面的 HTML 字符串会根据this.state
不同而不同(这里是用了 ES6 的模版字符串,做这种事情很方便)。- 新增一个
setState
函数,这个函数接受一个对象作为参数;它会设置实例的state
,然后重新调用一下render
方法。 - 当用户点击按钮的时候,
changeText
会构建新的state
对象,这个新的state
,传入setState
函数中。 这样的结果就是,用户每次点击,changeText
都会调用改变组件状态然后调用setState
;setState
会调用render
,render
方法会根据state
的不同重新构建不同的 DOM 元素。
刷新下页面,点击按钮,咦?怎么没反应了?
3.2 重新插入新的 DOM 元素
仔细看下代码,虽然点击按钮触发 changeText
方法,其中的 setState
方法中又触发 render
方法,给 this.el
赋值了一个新的DOM节点,但是我们并没有将这个DOM节点加到我们的DOM树中呀,只在在一开始 wrapper.appendChild(switchButton1.render())
,后来就没动过,页面上还是原来的DOM节点。所以,在组件的外面,需要知道这个组件发生了改变,并且把新的 DOM 元素更新到页面当中。
修改一下组件 setState
方法:
setState (state) {
const oldEl = this.el
this.state = state
this.el = this.render()
if (this.onStateChange) this.onStateChange(oldEl, this.el)
}
复制代码
onStateChange
的目的就是当组件发生变化时,如何处理原来的元素和新的元素,使用这个组件的时候,指定 onStateChange
的逻辑:
const wrapper = document.querySelector('.wrapper')
const switchButton1 = new SwitchButton()
wrapper.appendChild(switchButton1.render())
switchButton1.onStateChange = (oldEl, newEl) => {
wrapper.insertBefore(newEl, oldEl) // 插入新的元素
wrapper.removeChild(oldEl) // 删除旧的元素
}
复制代码
这里每次 setState
都会调用 onStateChange
方法,而这个方法是实例化以后时候被设置的,所以你可以自定义 onStateChange
的行为。这里的行为是,每当 setState
中构造完新的 DOM 元素以后,就会通过 onStateChange
告知外部插入新的 DOM 元素,然后删除旧的元素,页面就更新了,现在不需要再手动更新页面了。
这个版本的开关按钮不错,不需要手动操作DOM。但是有一个不好的地方,如果我要重新另外做一个新组件,譬如说评论组件,那么里面的这些 setState
方法要重新写一遍,其实这些东西都可以抽出来,变成一个通用的模式。
4、抽象出公共组件类
4.1 抽象组件类
为了让代码更灵活,可以写更多的组件,我们把这种模式抽象出来,放到一个 Component
类当中:
class Component {
setState (state) {
const oldEl = this.el
this.state = state
this._renderDOM()
if (this.onStateChange) this.onStateChange(oldEl, this.el)
}
_renderDOM () {
this.el = strToDOM(this.render())
if (this.onClick) {
this.el.addEventListener('click', this.onClick.bind(this), false)
}
return this.el
}
}
复制代码
这个是一个组件父类 Component
,所有的组件都可以继承这个父类来构建。它定义的两个方法,一个是我们已经很熟悉的 setState
;一个是私有方法 _renderDOM
。_renderDOM
方法会调用 this.render
来构建 DOM 元素并且监听 onClick
事件。所以,组件子类继承的时候只需要实现一个返回 HTML 字符串的 render
方法就可以了。
添加一个额外的 mount
的方法,其实就是把组件的 DOM 元素插入页面,并且设置 onStateChange
,告诉程序在 setState
的时候如何更新页面:
const mount = (component, wrapper) => {
wrapper.appendChild(component._renderDOM())
component.onStateChange = (oldEl, newEl) => {
wrapper.insertBefore(newEl, oldEl)
wrapper.removeChild(oldEl)
}
}
复制代码
这样的话,我们简化后的开关组件就会变成:
class SwitchButton extends Component {
constructor () {
super()
this.state = { on: false }
}
onClick () {
this.setState({
on: !this.state.on
})
}
render () {
return `
<button class='switch-btn'>
<span class='switch-text'>${this.state.on ? 'on' : 'off'}</span>
</button>
`
}
}
复制代码
使用新的 mount
方法将组件挂载到页面上:
const wrapper = document.querySelector('.wrapper')
mount(new SwitchButton(), wrapper)
复制代码
这样还不够好。在实际开发当中,你可能需要给组件传入一些自定义的配置数据。例如说想配置一下按钮的背景颜色,如果我给它传入一个参数,告诉它怎么设置自己的颜色。所以我们可以给组件类和它的子类都传入一个参数 props
,作为组件的配置参数。添加Component
的构造函数为:
constructor (props = {}) {
this.props = props
}
复制代码
SwitchButton
继承 Component
的时候通过 super(props)
把 props
传给父类,这样就可以通过 this.props
获取到配置参数。同时修改了一下原有的 SwitchButton
的 render
方法,让它可以根据传入的参数 this.props.bgColor
来生成不同的 style
属性。这样就可以自由配置组件的颜色了。
class SwitchButton extends Component {
constructor (props) {
super(props)
this.state = { on: false }
}
onClick () {
this.setState({
on: !this.state.on
})
}
render () {
return `
<button class='switch-btn' style="background-color: ${this.props.bgColor}">
<span class='switch-text'>${this.state.on ? 'on' : 'off'}</span>
</button>
`
}
}
复制代码
实例化组件并挂载的时候,将props传入构造函数:
mount(new SwitchButton({ bgColor: 'red' }), wrapper)
复制代码
4.2 定义一个新组件
如果我们需要新建一个组件,它是一个会改变颜色的文本,初始是红色,点击之后会变成蓝色,我们只要定义一个继承了Component
的 RedBlueText
类就行了:
class RedBlueText extends Component {
constructor (props) {
super(props)
this.state = {
color: 'red'
}
}
onClick () {
this.setState({
color: 'blue'
})
}
render () {
return `
<div style='color: ${this.state.color};'>${this.state.color}</div>
`
}
}
复制代码
用 mount
挂载:
mount(new RedBlueText(), wrapper)
复制代码
4.3 完整代码
现在可以灵活地组件化页面了,完整的代码在这里:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class='wrapper'>
</div>
<script>
const strToDOM = (domString) => {
const div = document.createElement('div')
div.innerHTML = domString
return div
}
const mount = (component, wrapper) => {
wrapper.appendChild(component._renderDOM())
component.onStateChange = (oldEl, newEl) => {
wrapper.insertBefore(newEl, oldEl)
wrapper.removeChild(oldEl)
}
}
class Component {
constructor (props = {}) {
this.props = props
}
setState (state) {
const oldEl = this.el
this.state = state
this._renderDOM()
if (this.onStateChange) this.onStateChange(oldEl, this.el)
}
_renderDOM () {
this.el = strToDOM(this.render())
if (this.onClick) {
this.el.addEventListener('click', this.onClick.bind(this), false)
}
return this.el
}
}
class SwitchButton extends Component {
constructor (props) {
super(props)
this.state = { on: false }
}
onClick () {
this.setState({
on: !this.state.on
})
}
render () {
return `
<button class='switch-btn' style="background-color: ${this.props.bgColor}">
<span class='switch-text'>${this.state.on ? 'on' : 'off'}</span>
</button>
`
}
}
class RedBlueText extends Component {
constructor (props) {
super(props)
this.state = {
color: 'red'
}
}
onClick () {
this.setState({
color: 'blue'
})
}
render () {
return `
<div style='color: ${this.state.color};'>${this.state.color}</div>
`
}
}
const wrapper = document.querySelector('.wrapper')
mount(new SwitchButton(), wrapper)
mount(new SwitchButton({ bgColor: 'red' }), wrapper)
mount(new RedBlueText(), wrapper)
</script>
</body>
</html>
复制代码
5、总结
组件化可以帮助我们解决前端结构的复用性问题,整个页面可以由这样的不同的组件组合、嵌套构成。
一个组件有自己的显示形态(上面的 HTML 结构和内容)行为,组件的显示形态和行为可以由数据状态(state)和配置参数(props)共同决定。数据状态和配置参数的改变都会影响到这个组件的显示形态。
当数据变化的时候,组件的显示需要更新。所以如果组件化的模式能提供一种高效的方式自动化地帮助我们更新页面,那也就可以大大地降低我们代码的复杂度,带来更好的可维护性。