40 行代码内实现一个 React.js

做者:胡子大哈
原文连接:http://huziketang.com/blog/posts/detail?postId=58aea515204d50674934c3achtml

转载请注明出处,保留原文连接和做者信息。前端

目录

  • 1 前言react

  • 2 一切从点赞提及git

  • 3 实现可复用性github

    • 3.1 结构复用算法

    • 3.2 生成 DOM 元素而且添加事件app

  • 4 为何不暴力一点?dom

    • 4.1 状态改变 -> 构建新的 DOM 元素函数

    • 4.2 从新插入新的 DOM 元素组件化

  • 5 抽象出 Component 类

  • 6 总结

1 前言

本文会教你如何在 50 行代码内,不依赖任何第三方的库,用纯 JavaScript 实现一个 React.js 。

本文的目的是:揭开对初学者看起来很很难理解的 React.js 的组件化形式的外衣,让你有更多的精力和注意力去学习 React.js 精髓的地方。若是你刚开始学习 React.js 而且感受很迷茫,那么看完这篇文章之后就可以解除一些疑惑。

另外注意,本文所实现的代码只用于说明教学展现,并不适用于生产环境。代码托管这个 仓库 。心急如焚的同窗能够先去看代码,但本文会从最基础的内容开始解释。

2 一切从点赞提及

接下来全部的代码都会从一个基本的点赞功能开始演化,你会逐渐看到,文章代码慢慢地愈来愈像 React.js 的组件代码。而在这个过程里面,你们须要只须要跟着文章的思路,就能够在代码的演化当中体会到组件化形式。

假设如今咱们须要实现一个点赞、取消点赞的功能。

[image:B4B41FF2-519A-4A7C-8035-0D5CD4EE8FFA-86900-00013723B2CAE361/8D274601-162D-4B36-B1E0-9C65FB0C494F.png]

若是你对前端稍微有一点了解,你就顺手拈来:

HTML:

<body>
    <div class='wrapper'>
      <button class='like-btn'>
        <span class='like-text'>点赞</span>
        <span>?</span>
      </button>
    </div>
  </body>

为了现实当中的实际状况,因此这里特易把这个 button 的 HTML 结构搞得稍微复杂一些。有了这个 HTML 结构,如今就给它加入一些 JavaScript 的行为:

JavaScript:

const button = document.querySelector('.like-btn')
  const buttonText = button.querySelector('.like-text')
  let isLiked = false
  button.addEventListener('click', function () {
    isLiked = !isLiked
    if (isLiked) {
      buttonText.innerHTML = '取消'
    } else {
      buttonText.innerHTML = '点赞'
    }
  }, false)

功能和实现都很简单,按钮已经能够提供点赞和取消点赞的功能。这时候你的同事跑过来了,说他很喜欢你的按钮,他也想用你写的这个点赞功能。你就会发现这种实现方式很致命:你的同事要把整个 button 和里面的结构复制过去,还有整段 JavaScript 代码也要复制过去。这样的实现方式没有任何可复用性。

3 实现可复用性

因此如今咱们来想办法解决这个问题,让这个点赞功能具备较好的可复用的效果,那么你的同事们就能够轻松自在地使用这个点赞功能。

3.1 结构复用

如今咱们来从新编写这个点赞功能。此次咱们先写一个类,这个类有 render 方法,这个方法里面直接返回一个表示 HTML 结构的字符串:

class LikeButton {
    render () {
      return `
        <button id='like-btn'>
          <span class='like-text'>赞</span>
          <span>?</span>
        </button>
      `
    }
  }

而后能够用这个类来构建不一样的点赞功能的实例,而后把它们插到页面中。

const wrapper = document.querySelector('.wrapper')
  const likeButton1 = new LikeButton()
  wrapper.innerHTML = likeButton1.render()
  
  const likeButton2 = new LikeButton()
  wrapper.innerHTML += likeButton2.render()

[image:4AEFC6B6-F913-440E-9306-CCC454A7A30C-87312-00013B98FB6F8354/4555573C-8435-4079-9D64-C76913AB6E40.png]

这里很是暴力地使用了 innerHTML ,把两个按钮粗鲁地插入了 wrapper 当中。虽然你可能会对这种实现方式很是不满意,但咱们仍是勉强了实现告终构的复用。咱们后面再来优化它。

3.2 生成 DOM 元素而且添加事件

你必定会发现,如今的按钮是死的,你点击它它根本不会有什么反应。由于根本没有往上面添加事件。可是问题来了,LikeButton 类里面是虽说有一个 button,可是这玩意根本就是在字符串里面的。你怎么能往一个字符串里面添加事件呢?DOM 事件的 API 只有 DOM 结构才能用。

咱们须要 DOM 结构,准确地来讲:咱们须要这个点赞功能的 HTML 字符串表明的 DOM 结构。假设咱们如今有一个函数 createDOMFromString ,你往这个函数传入 HTML 字符串,可是它会把相应的 DOM 元素返回给你。这个问题就能够额解决了。

// ::String => ::Document
const createDOMFromString = (domString) => {
  // TODO 
}

先不用管这个函数应该怎么实现,先知道它是干吗的。拿来用就好,这时候用它来改写一下 LikeButton 类:

class LikeButton {
    render () {
      this.el = createDOMFromString(`
        <button class='like-button'>
          <span class='like-text'>点赞</span>
          <span>?</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 likeButton1 = new LikeButton()
  wrapper.appendChild(likeButton1.render())

  const likeButton2 = new LikeButton()
  wrapper.appendChild(likeButton2.render())

如今你点击这两个按钮,每一个按钮都会在控制台打印 click,说明事件绑定成功了。可是按钮上的文本仍是没有发生改变,只要稍微改动一下 LikeButton 的代码就能够完成完整的功能:

class LikeButton {
    constructor () {
      this.state = { isLiked: false }
    }

    changeLikeText () {
      const likeText = this.el.querySelector('.like-text')
      this.state.isLiked = !this.state.isLiked
      if (this.state.isLiked) {
        likeText.innerHTML = '取消'
      } else {
        likeText.innerHTML = '点赞'
      }
    }

    render () {
      this.el = createDOMFromString(`
        <button class='like-button'>
          <span class='like-text'>点赞</span>
          <span>?</span>
        </button>
      `)
      this.el.addEventListener('click', this.changeLikeText.bind(this), false)
      return this.el
    }
  }

这里的代码稍微长了一些,可是仍是很好理解。只不过是在给 LikeButton 类添加了构造函数,这个构造函数会给每个 LikeButton 的实例添加一个对象 statestate 里面保存了每一个按钮本身是否点赞的状态。还改写了原来的事件绑定函数:原来只打印 click,如今点击的按钮的时候会调用 changeLikeText 方法,这个方法会根据 this.state 的状态改变点赞按钮的文本。

若是你如今还能跟得上文章的思路,那么你留意下,如今的代码已经和 React.js 的组件代码有点相似了。但其实咱们根本没有讲 React.js 的任何内容,咱们一心一意只想怎么作好“组件化”。

如今这个组件的可复用性已经很不错了,你的同事们只要实例化一下而后插入到 DOM 里面去就行了。

4 为何不暴力一点?

仔细留意一下 changeLikeText 函数,这个函数包含了 DOM 操做,如今看起来比较简单,那是由于如今只有 isLiked 一个状态。但想一下,由于你的数据状态改变了你就须要去更新页面的内容,因此若是你的组件包含了不少状态,那么你的组件基本所有都是 DOM 操做。一个组件包含不少状态的状况很是常见,因此这里还有优化的空间:如何尽可能减小这种手动 DOM 操做?

4.1 状态改变 -> 构建新的 DOM 元素

这里要提出的一种解决方案:一旦状态发生改变,就从新调用 render 方法,构建一个新的 DOM 元素。这样作的好处是什么呢?好处就是你能够在 render 方法里面使用最新的 this.state 来构造不一样 HTML 结构的字符串,而且经过这个字符串构造不一样的 DOM 元素。页面就更新了!听起来有点绕,看看代码怎么写:

class LikeButton {
    constructor () {
      this.state = { isLiked: false }
    }

    setState (state) {
      this.state = state
      this.el = this.render()
    }

    changeLikeText () {
      this.setState({
        isLiked: !this.state.isLiked
      })
    }

    render () {
      this.el = createDOMFromString(`
        <button class='like-btn'>
          <span class='like-text'>${this.state.isLiked ? '取消' : '点赞'}</span>
          <span>?</span>
        </button>
      `)
      this.el.addEventListener('click', this.changeLikeText.bind(this), false)
      return this.el
    }
  }

其实只是改了几个小地方:

  1. render 函数里面的 HTML 字符串会根据 this.state 不一样而不一样(这里是用了 ES6 的字符串特性,作这种事情很方便)。

  2. 新增一个 setState 函数,这个函数接受一个对象做为参数;它会设置实例的 state,而后从新调用一下 render 方法。

  3. 当用户点击按钮的时候, changeLikeText 会构建新的 state 对象,这个新的 state ,传入 setState 函数当中。

这样的结果就是,用户每次点击,changeLikeText 都会调用改变组件状态而后调用 setStatesetState 会调用 render 方法从新构建新的 DOM 元素;render 方法会根据 state 的不一样构建不一样的 DOM 元素。

也就是说,你只要调用 setState,组件就会从新渲染。咱们顺利地消除了不必的 DOM 操做。

4.2 从新插入新的 DOM 元素

上面的改进不会有什么效果,由于你仔细看一下就会发现,其实从新渲染的 DOM 元素并无插入到页面当中。因此这个组件以外,你须要知道这个组件发生了改变,而且把新的 DOM 元素更新到页面当中。

从新修改一下 setState 方法:

...
    setState (state) {
        const oldEl = this.el
      this.state = state
      this.el = this.render()
        if (this.onStateChange) this.onStateChange(oldEl, this.el)
    }
...

使用这个组件的时候:

const likeButton = new LikeButton()
wrapper.appendChild(likeButton.render()) // 第一次插入 DOM 元素
component.onStateChange = (oldEl, newEl) => {
  wrapper.insertBefore(newEl, oldEl) // 插入新的元素
  wrapper.removeChild(oldEl) // 删除旧的元素
}

这里每次 setState 都会调用 onStateChange 方法,而这个方法是实例化之后时候被设置的,因此你能够自定义 onStateChange 的行为。这里作的事是,每当 setState 的时候,就会把插入新的 DOM 元素,而后删除旧的元素,页面就更新了。这里已经作到了进一步的优化了:如今不须要再手动更新页面了。

非通常的暴力。不过没有关系,这种暴力行为能够被 Virtual-DOM 的 diff 策略规避掉,但这不是本文章所讨论的范围。

这个版本的点赞功能很不错,我能够继续往上面加功能,并且还不须要手动操做DOM。可是有一个很差的地方,若是我要从新另外作一个新组件,譬如说评论组件,那么里面的这些 setState 方法要从新写一遍,其实这些东西均可以抽出来。

5 抽象出 Component 类

为了让代码更灵活,能够写更多的组件,我把这种模式抽象出来,放到一个 Component 类当中:

class Component {
    constructor (props = {}) {
      this.props = props
    }

    setState (state) {
      const oldEl = this.el
      this.state = state
      this.el = this.renderDOM()
      if (this.onStateChange) this.onStateChange(oldEl, this.el)
    }

    renderDOM () {
      this.el = createDOMFromString(this.render())
      if (this.onClick) {
        this.el.addEventListener('click', this.onClick.bind(this), false)
      }
      return this.el
    }
  }

还有一个额外的 mount 的方法,其实就是把组件的 DOM 元素插入页面,而且在 setState 的时候更新页面:

const mount = (wrapper, component) => {
    wrapper.appendChild(component.renderDOM())
    component.onStateChange = (oldEl, newEl) => {
      wrapper.insertBefore(newEl, oldEl)
      wrapper.removeChild(oldEl)
    }
  }

这样的话咱们从新写点赞组件就会变成:

class LikeButton extends Component {
    constructor (props) {
      super(props)
      this.state = { isLiked: false }
    }

    onClick () {
      this.setState({
        isLiked: !this.state.isLiked
      })
    }

    render () {
      return `
        <button class='like-btn'>
          <span class='like-text'>${this.props.word || ''} ${this.state.isLiked ? '取消' : '点赞'}</span>
          <span>?</span>
        </button>
      `
    }
  }

  mount(wrapper, new LikeButton({ word: 'hello' }))

有没有发现你写的代码已经和 React.js 的组件写法很类似了?并且仍是能够正常运做的代码,并且咱们从头至尾都是用纯的 JavaScript,没有依赖任何第三方库。(注意这里加入了上面没有提到过点 props,能够给组件传入配置属性,跟 React.js 同样)。

只要有了上面那个 Component 类和 mount 方法加起来不足40行代码就能够作到组件化。若是咱们须要写另一个组件,只须要像上面那样,简单地继承一下 Component 类就行了:

class RedBlueButton 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>
      `
    }
  }

简单好用,完整的代码能够在这里找到: 仓库

噢,忘了,还有一个神秘的 createDOMFromString,其实它更简单:

const createDOMFromString = (domString) => {
    const div = document.createElement('div')
    div.innerHTML = domString
    return div
  }

6 总结

你到底从文章能从文章中获取到什么?

好吧,我认可我标题党了,这个 40 行不到的代码实际上是一个残废并且智障版的 React.js,没有 JSX ,没有组件嵌套等等。它只是 React.js 组件化表现形式的一种实现而已。它根本没有触碰到 React.js 的精髓。

其实 React.js 的最最精髓的地方可能就在于它的 Virtual DOM 算法,而它的 setStateprops 等等都只不过是一种形式,而不少初学者会被它这种形式做迷惑。本篇文章其实就是揭露了这种组件化形式的实现原理。若是你正在学习或者学习 React.js 过程很迷茫,那么看完这篇文章之后就可以解除一些疑惑。

本文并无涉及到 Virtual DOM 的任何内容,有须要的同窗能够参考一下这篇博客 ,介绍的很详尽。有兴趣的同窗能够把二者结合起来,把 Virtual DOM 替代本文暴力处理的 mount 中的实现,真正实现一个 React.js。

若是你对本文的内容有疑惑,能够关注个人知乎专栏而且评论或者给我知乎发私信。


我最近正在写一本《React.js 小书》,对 React.js 感兴趣的童鞋,欢迎指点

相关文章
相关标签/搜索