如何实现一个前端框架3-组件化初步

217 阅读10分钟

目前前端的框架有一个很重要的功能就是组件,我们可以通过组件来实现页面上各个模块的抽象以及复用,这一节我们通过模仿element中的el-alert这个组件为例来介绍组件化的实现,当然,这个过程我简化了若干细节部分,只是把最核心的流程提取了出来

我们要做的效果大概是这样,页面刚加载完时,只有一个按钮: 用户点这个按钮,可以展示alert组件,之后,用户还可以自己点alert组件右边的叉号,隐藏掉它,大概是这样:

由于点击alert右侧的叉号使组件隐藏这个功能涉及组件的回调,所以我们稍后再考虑

那么,根据我们使用Vue或React的经验,组件定义的代码可能会写成这样: 父组件App的render(由于父组件App其他部分和上一节没区别,所以就不重复贴代码了):

  render: function () {
    let children = [
      this.createElement('button', {
        class: 'click-show',
        on: {
          click: this.showAlert
        }
      }, '点击展示')
    ]
    if (this.alertVisible) {
      // my-alert就是自定义组件
      let o = this.createElement('my-alert', { 
        props: {
          content: '左侧文字'
        }
      })
      children.push(o)
    }
    return this.createElement('div', { class: 'alert-wrap' }, children)
  }

其中的my-alert就是我们的自定义组件,根据组件化设计思路,my-alert会额外再用一个对象来定义:

let MyAlert = {
  props: {
    content: {
      default: ''
    },
  },
  data: function () {
    return {
    }
  },
  methods: {
  },
  render: function () {
    let children = [
      this.createElement('div', { class: 'text' }, this.content)
    ]
    return this.createElement('div', { class: 'alert' }, children)
  }
}

可以看到my-alert这个组件,也和最顶层的App组件一样,有data、methods、render等等,不同的是my-alert还有props,这个props代表从父组件传给子组件的属性,content属性代表里面展示的文字

我们可以考虑一下实现这个功能的大致思路:
既然my-alert和app一样,那么二者也一定都会经历initData、initMethods等初始化、renderAndCollectDependencies(渲染及收集依赖等过程),在 render的过程中会调用createElement创建DOM

和上一节不同的是,我们在调用createElement过程中既会遇到浏览器支持的div、header等这些标签,又会遇到我们自定义的my-alert这种标签,而遇到自定义标签时,我们应该找到对应的myAlert组件,将其实例化,并拿到它render之后的dom,放到父组件(这里就是App)的render方法的children中

整理一下,大概过程就是:

App.init -> App.update -> App.render -> App.createElement
							 -> MyAlert.init -> MyAlert.update -> MyAlert.render -> MyAlert.createElement

有了这个思路之后,我们就要思考在这个过程中出现的一些问题?

1、如何将自定义标签和组件对象关联起来?

这个问题是什么意思呢?
首先无论是Vue还是React,通常我们都是在模板中通过自定义标签(例如我们这里的my-alert),然后在这个标签中传入属性(createElement的第二个参数中的props)、事件的名称,以此来初始化、更新组件的,例如我们上面的代码中,父组件App的render中的my-alert就是子组件

由于my-alert最终会交给createElement来创建,我们现在的createElement是只支持创建HTML内置DOM对象的,要让它支持创建自定义组件,那么在函数内部遇到tagName不是浏览器支持的标签名时,就一定要做特殊处理

但createElement这个函数接受到的tagName参数是'my-alert'这样一个字符串,如何通过'my-alert'找到Alert的定义呢?

这就是我们需要解决的第一个问题

2、父子组件分别什么时候实例化?从父组件实例化,到子组件实例化这个过程是如何衔接的?

对于根组件来说,它实例化的时机是非常明显的,对于Vue来说,通常是在入口文件main.js中:

import App from './App.vue'
new Vue({
	...App
})

但App根组件下面render中的组件都是在什么时候初始化的呢?这些组件的初始化和根组件有什么区别呢?

3、子组件怎么知道它要插入到哪里?它插入的时机和根组件有什么区别?(这个问题也可以这么问:子组件插入的位置和时机如何处理?)

我们可以先分析一下根组件是怎么插入父级中的
对于根组件来说,我们给了el这个参数,来告诉它最后插入到这个el在的这个地方就可以

但是对于子组件来说,就没有这个参数了,它插入的位置是和父组件具体的结构有关的

子组件的插入,是在父组件render过程中进行的,父组件在render的过程中遇到一个不认识的标签,就开始找到这个标签对应的组件的定义,找到组件定义之后就实例化、调init、调update

到这里,有一点值得注意,我们之前插入根组件时,是获取this.el、this.renderedElement的父级和它在所有兄弟节点中的位置,来找到要插入到哪个父元素下面的哪个子元素前面

但是对于子组件来说,第一次创建出DOM对象后this.el、this.renderedElement这两个变量都没有值,这样直接去取parentNode、nextElementSibling一定会报错,那这个地方要如何兼容子组件的update呢?

==================================================================== 沿着我们最初提出的思路,带着上述问题,我们开始一步步让我们的框架实现组件化

从哪里开始入手呢?
我们其实可以沿着上一节实现的代码,往下想,看看到哪里就走不下去了
App.init -> App.update -> App.render这些过程似乎还是可以走下去的
但是,接下来走到createElement时,会遇到未知my-alert,这个逻辑是之前没有的
所以我们从这里开始入手:

ViewComponent.prototype.createElement = function (tagName, attrs, childNodes) {
  if (isReservedTag(tagName)) {
    return createHTMLElement(tagName, attrs, childNodes)
  } else if (xxx) {
    return createComponent(tagName, attrs, childNodes)
  }
}

从代码中,可以看到,我们根据tagName做了不同的处理,else if部分就是对组件的处理,isReservedTag就是处理浏览器支持的标签,这部分逻辑比较简单,不再赘述,我们重点看else if的条件

这个条件想要达到的目的很明显,就是根据tagName去找组件的定义,如果能找到,那就可以进这个分支
其实,走到这里,也就遇到我们在上面提出的第1个问题,我们可以参考一下大神们是如何做的

Vue是通过在组件的options中,定义components这么一个属性来索引要用到的组件的,所以我们在定义组件时,如果用到了其他组件,需要加上components这个属性,在此我们也仿照Vue的做法来实现一下:

new ViewComponent({
  el: '#app',
  components: {
    MyAlert: MyAlert
  },
  data: function () {
    return {
      alertVisible: false
    }
  },
  methods: {
    showAlert: function () {
      this.alertVisible = true
    }
  },

当然,Vue中有局部组件,还有全局组件,Vue通过如下函数路径提供了全局组件的注册方法: import Vue from 'vue' -> initGlobalAPI -> initAssetRegisters

然后,用户在通过Vue.component注册组件时,会将其放到Vue.options.components中,而每个组件在初始化的时候,都会通过调用mergeOptions将其合并到组件自己的components中,这样组件自己就可以拿到全局组件了

其实,我觉得,还可以将全局组件统一放到一个地方,在createElement中寻找组件时,先去组件定义的components中找,如果找不到,再去全局找

当然,我们这里就不去实现这个逻辑了,只要知道它有这样一种实现思路就好,这个并不复杂

回到我们的代码中,有了方案之后,实现起来就简单多了:

ViewComponent.prototype.createElement = function (tagName, attrs, childNodes) {
  if (isReservedTag(tagName)) {
    return createHTMLElement(this, tagName, attrs, childNodes)
  } else if (isValidComponent(this, tagName)) {
    return createComponent(this, tagName, attrs, childNodes)
  }
}

function isValidComponent (vm, tagName) {
  let normalizedComponentName = kebabToCamelCase(tagName)
  return vm.components[normalizedComponentName]
}

沿着这个思路往下,我们就要考虑createComponent的实现了
我们的目的是得到render之后返回的DOM对象,这个DOM对象挂在ViewComponent组件实例对象的renderedElement这个属性上,无论是调用render也好、得到renderedElement之后挂在实例上也好,前提是得有ViewComponent实例,所以这里就是实例化子组件的地方:

function createComponent (vm, tagName, attrs, childNodes) {
  let normalizedComponentName = kebabToCamelCase(tagName)
  let componentOptions = vm.components[normalizedComponentName] || {}
  let propsData = attrs.props

  componentOptions.propsData = propsData
  // 前面提到的第2个问题,子组件实例化的时机问题
  let componentInstance = new ViewComponent(componentOptions)
  return componentInstance.renderedElement
}

同时,我们也可以看到,我们将组件定义中的props也接管过来放到了options中去,关于属性的问题我们稍后再讨论

到此为止,我们开头提出的第2个问题,也有了答案

接下来我们继续探索,通过new ViewComponent实例化子组件的过程中会出现什么问题

需要注意,在isValidComponent、createComponent两个方法中,我们直接从vm上去取了components这个属性,因此需要在初始化的时候,将options上的components放到this上, props也是同理:

function ViewComponent (options) {
  this.el = options.el ? document.querySelector(options.el) : undefined
  // props也放到了this上
  this.propOptions = options.props
  this.data = options.data
  this.methods = options.methods
  this.render = options.render
  // options.components直接放到this上
  this.components = options.components
  this.$options = options
  this.renderedElement = null
  this.init()
}

接下来,ViewComponent构造函数就开始走init -> renderAndCollectDependencies -> update了

在update中我们的第3个问题,就开始体现出来

但第3个问题的解决,所使用的代码却是非常少:

ViewComponent.prototype.update = function () {
  // 清除旧的DOM
  let oldRenderedElement = this.renderedElement || this.el
  // 这里给renderedElement赋值,是为了将它留给子组件下一次update执行时赋值给oldRenderedElement
  this.renderedElement = this.render()
  if (oldRenderedElement) {
    let parent = oldRenderedElement.parentNode
    let sibling = oldRenderedElement.nextElementSibling
    parent.removeChild(oldRenderedElement)
    if (sibling) {
      parent.insertBefore(this.renderedElement, sibling)
    } else {
      parent.appendChild(this.renderedElement)
    }
  }
}

可以看到,我们只在最外层加了一个if判断,用于兼容子组件实例化时执行update的情况

也就是说,在子组件实例化时,由于this.renderedElement是undefined,this.el也没有传,所以oldRenderedElement也将是undefined,也正是如此,这个时候我们根本不知道、更找不到它的parentNode,所以就不在这个时候将它append到parentNode了

那它append到parentNode中的时机是什么时候呢?

其实,子组件实例化时的update(同时也是第一次update)到此为止就执行完了,再往后,就会依次返回到renderAndCollectDependencies、init、ViewComponent构造函数、父组件(即App)调用的createComponent中

在createComponent方法中会通过componentInstance这个局部变量来将my-alert子组件实例接过来,通过这个componentInstance实例,我们可以拿到它创建好的DOM对象:componentInstance.renderedElement

function createComponent (vm, tagName, attrs, childNodes) {
  let normalizedComponentName = kebabToCamelCase(tagName)
  let componentOptions = vm.components[normalizedComponentName] || {}
  let propsData = attrs.props
  let events = attrs.on

  componentOptions.propsData = propsData
  componentOptions.parentListeners = events
  componentOptions.$parent = vm
  let componentInstance = new ViewComponent(componentOptions)
  // 拿到renderedElement,这就是创建好的子组件DOM对象
  return componentInstance.renderedElement
}

再往后,createComponent方法也执行完了,继续返回到父组件App的createElement、父组件的render,将其作为父组件的一个children插入进去:

  render: function () {
    let children = [
      this.createElement('button', {
        class: 'click-show',
        on: {
          click: this.showAlert
        }
      }, '点击展示')
    ]
    if (this.alertVisible) {
      // o最终存储着my-alert子组件对应的DOM对象
      let o = this.createElement('my-alert', { 
        props: {
          content: '左侧文字'
        },
        on: {
          close: this.hideAlert
        }
      })
      // o会被放到children中
      children.push(o)
    }
    // children被插入到父组件中
    return this.createElement('div', { class: 'alert-wrap' }, children)

总结一下:createComponent的返回结果会最终作为我们自己在父组件定义中的render方法中调用createElement的返回值(有点啰嗦,但似乎又很简单)

我们上面提出的第3个问题,也就有了答案,子组件并不会在update中插入到父组件,而是将这个插入操作延迟到了父组件render中,被作为children插入到父组件中

createComponent的返回值,其实就是MyAlert子组件实例化过程中,调用render得到的DOM:

  render: function () {
    let children = [
      this.createElement('div', { class: 'text' }, this.content)
    ]
    return this.createElement('div', { class: 'alert' }, children)
  }

如果子组件在render的过程中,又遇到了自定义标签,即组件的情况,那就又会走: this.createComponent -> new ViewComponent(componentOptions) -> this.init -> this.update
走到update之后,oldRenderedElement也会是undefined,插入的操作,也会推迟到父组件的render函数里面

题外话:由于我们目前没有引入虚拟DOM的概念,也就是说,createElement调用完了之后直接生成了真实DOM,在后期我们用了虚拟DOM之后,这个子组件插入父组件的时机可能还会略有不同,到时候再详细说明

本篇文章写完之后的感受: 我们平时完成一个功能时,无论是业务上的,还是这种框架设计上的,在做这个功能之前能预见各种问题和一边做一边遇到问题,再一边解决,其实是完全不同的两种效果

在做之前预见问题能够很好地反映一个开发人员对问题的拆解、思考、辨析等一系列能力

很遗憾的是,本文刚刚提出的这些问题,其实是我在读了很多遍源码后,自己在实现框架的过程中遇到的各种问题,我在写的时候,将它放在了前面

所以,今后好好加油吧