Vue.js设计与实现之组件的实现原理

更新日期: 2022-04-25阅读: 912标签: 组件

1 . 写在前面

上篇文章介绍使用虚拟节点来描述组件,讨论了组件在挂载的时候,响应式数据发生变化会导致组件频繁渲染,对此采用微任务队列可以避免频繁执行。介绍了如何创建组件实例,通过instance实例上的isMounted的状态,区分组件的挂载与更新。那么本文将继续讲解组件的实现细节。

2 . props与组件的被动更新

props

在虚拟dom中,组件的props和普通html标签上的属性差别并不大。

<MyComponent name="pingping" age="18"/>

对应的虚拟DOM是:

const vnode = {
  type: MyComponent,
  props: {
    name:"pingping",
    age:18
  }
}

对于组件而言:

const MyComponent = {
  name:"MyComponent",
  props:{
    name:String,
    age: Number
  },
  render(){
    return {
      type:"div",
      children:`my name is ${this.name}, my age is: ${this.age}`
    }
  }
}

对于组件而言,需要关心的props内容有两部分:

  • 为组件传递数据的props,即vnode.props对象
  • 组件内部选项自定义的props,即MyComponent.props

组件在渲染时解析props数据需要结合这两个选项,最终解析出组件在渲染时需要使用到的props和attrs。

function mountComponent(vnode, container, anchor){
  const componentOptions = vnode.type;
  // 从组件选项中获取到的props对象即propsOption
  const { render, data,props: propsOption } = componentOptions;
  
  // 在数据初始化前
  beforeCreate && beforeCreate();
  // 将原始数据对象data封装成响应式数据
  const state = reactive(data());
  
  // 调用resolveProps 函数解析最终的props和attrs数据
  const [props, attrs] = resolveProps(propsOptions, vnode.props);
  
  // 组件实例
  const instance = {
    // 组件状态数据
    state,
    // 组件挂载状态
    isMounted: false,
    // 组件渲染内容
    subTree: null,
    props: shallowReactive(props);
  }
  
  // 将组件实例设置在vnode上,方便后续更新
  vnode.component = instance;
  //... 代码省略
}

再看看将props解析成最终的props和attrs的resolveProps函数:

在上面代码中,没有定义在组件的props选项中的props数据将会被存储在attrs对象中,实际上还需要对其进行默认值处理。

function resolveProps(options, propsData){
  const props = {};
  const attrs = {};
  //遍历为组件传递的props数据
  for(const key in propsData){
    // 鉴别是否为组件约定的props
    if(key in options){
      props[key] = propsData[key];
    }else{
      attrs[key] = propsData[key];
    }
  }
  return [props, attrs]
}

组件的被动更新

其实,子组件的props数据本质上就是来自于父组件传递的,在props发生变化时,会触发父组件的重新渲染。

假定父组件初次要渲染的虚拟DOM:

const vnode = {
  type: MyComponent,
  props:{
    name:"pingping",
    age:18
  }
}

在name或age的数据发生变化时,父组件的渲染函数会重新执行,从而产生新的虚拟DOM:

const vnode = {
  type: MyComponent,
  props:{
    name:"onechuan",
    age:18
  }
}

由于父组件要渲染的虚拟DOM内容发生变化,此时就需要进行自更新,在更新时会使用patchComponent函数进行子组件的更新。

function patch(n1, n2, container, anchor){
  if(n1 && n1.type !== n2.type){
    unmount(n1);
    n1 = null;
  }
  const {type} = n2;
  
  if(typeof type === "string"){
    //...普通元素
  }else if(typeof type === Text){
    //...文本节点
  }else if(typeof type === Fragement){
    //...片段
  }else if(typeof type === "object"){
    // vnode.type的值是选项对象,作为组件处理
    if(!n1){
      //挂载组件
      mountComponent(n2, container, anchor);
    }else{
      //更新组件
      patchComponent(n1, n2, anchor);
    }
  }
}

由父组件更新引起的子组件更新叫做子组件的被动更新,在子组件更新时需要检测子组件是否真的需要更新,如果需要更新则更新子组件的props和slots等内容。具体的patchComponent代码如下所示:

function patchComponent(n1, n2, anchor){
  //获取组件实例,新旧组件实例是一样的
  const instance = (n2.component = n1.component);
  const {props} = instance;
  
  if(hasPropsChanged(n1.props, n2.props)){
    const [nextProps] = resovleProps(n1.props, n2.props);
    // 更新props
    for(const k in nextProps){
      props[k] = nextProps[k]
    }
    // 删除不存在的props
    for(const k in props){
      if(!(k in nextProps)) delete props[k];
    }
  }
}

hasPropsChanged函数用于判断新旧props内容是否有改动,有改动则进行组件的更新。

function hasPropsChanged(prevProps, nextProps){
  const nextKeys = Object.keys(nextProps);
  cosnt prevKeys = Object.keys(prevProps);
  // 新旧数量是否改变
  if(nextKeys.length !== prevKeys.length){
    return true
  }
  // 是否有不相等的props
  for(let i = 0; i < nextKeys.length; i++){
    const key = nextKeys[i];
    if(nextProps[key] !== prevProps[key]) return true
  }
  return false
}

props和attrs本质上都是根据组件的props选项定义和给组件传递的props数据进行处理的。但是由于props数据与组件本身的状态数据都需要暴露到渲染函数中,渲染函数中可以通过this进行访问,对此需要封装一个渲染上下文对象。

function mountComponent(vnode, container, anchor){
  // 省略代码...
  
  // 组件实例
  const instance = {
    state,
    isMounted: false,
    subTree: null,
    props: shallowReactive(props);
  }
  
  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r){
      // 获取组件自身状态和props数据
      const {state, props} = t;
      // 先尝试读取自身数据
      if(state && k in state){
        return state[k]
      }else if(k in props){
        return props[k]
      }else{
        console.log("不存在");
      }
    },
    set(t, k, v, r){
      const {state, props} = t;
      if(state && k in state){
        state[k] = v
      }else if(k in props){
        props[k] = v
      }else{
        console.log("不存在");
      }
    }
  })
 
   created && created.call(renderCOntext
  //代码省略...
}

在上面代码中,通过为组件实例创建一个代理对象,即渲染上下文对象,对数据状态拦截实现读取和设置操作。在渲染函数或生命周期钩子中可以通过this读取数据时,会优先从组件自身状态中获取,倘若组件自身没有对应数据,则从props数据中进行读取。渲染上下文对象其实就是作为渲染函数和生命周期钩子的this值。

当然,渲染上下文对象处理的不仅仅是组件自身的数据和props数据,还包括:methods、computed等选项的数据和方法。

3 . setup函数的作用与实现

组件的setup函数是vue.js3新增的组件选项,主要用于配合组合式api进行建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup函数只会在被挂载时执行一次,返回值可以是组件的渲染函数也可以是暴露出的响应式数据到渲染函数中。

const Comp = {
  //setup函数可以返回一个函数作为组件的渲染函数
  setup(){
    return ()=>{
      return {
        type:"div",
        children:"pingping"
      }
    }
  }
}

但是,这种方式通常用于不是以模板来渲染内容,如果组件是模板来渲染内容,那么setup函数就不可以返回函数,否则会与模板编译的渲染函数冲突。

返回对象的情况,是将对象的数据暴露给模板使用,setup函数暴露的数据可以通过this进行访问。

const Comp = {
  props:{
    name:String
  },
  //setup函数可以返回一个函数作为组件的渲染函数
  setup(props, setupContext){
    console.log(`my name is ${props.name}`);
    const age = ref(18);
    // setupContex包含与组件接口相关的重要数据
    const {slots, emit, attrs} = setupContext;
    return {
      age
    }
  },
  render(){
    return {
      type:"div",
      children:`my age is ${this.age}`
    }
  }
}

那么setup函数是如何设计与实现的呢?

function mountComponent(vnode, container, anchor){ 
  const componentOptions = vnode.type;
  //从选项组件中取出setup函数
  let {render, data, setup, /*...*/} = componentOptions;
  
  beforeCreate && beforeCreate();
  
  const state = data ? reactive(data()) : null;
  const [props, attrs] = resolveProps(propsOption, vnode.props);
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null
  }
  
  const setupContext = { attrs };
  const setupResult = setup(shallowReadonly(instance.props), setupContext);
  // 存储setup返回的数据
  let setupState = null;
  // 判断setup返回的是函数还是数据对象
  if(typeof setupResult === "function"){
    // 报告冲突
    if(render) console.error("setup函数返回渲染函数,render选项可以忽略");
    render = setupResult;
  }else{
    setupState = setupContext;
  }
  
  vnode.component = instance;
  
  const renderContext = new Proxy(instance,{
    get(t, k, r){
      const {state, props} = t;
      if(state && k in state){
        return state[k];
      }else if(k in props){
        return props[k]
      }else if(setupState && k in setupState){
        return setupState[k]
      }else{
        console.log("不存在");
      }
    },
    set(t, k, v, r){
      const {state, props} = t;
      if(state && k in state){
        state[k] = v;
      }else if(k in props){
        props[k] = v;
      }else if(setupState && k in setupState){
        setupState[k] = v;
      }else{
        console.log("不存在");
      }
    }
  })
  //省略部分代码...
}

4 . 组件事件与emit的实现

emit是用于父组件传递方法到子组件,是一个发射事件的自定义事件。

<MyComponent @change="handle"/>

上面组件的虚拟DOM:

const CompVNode = {
  type:MyComponent,
  props:{
    onChange:handler
  }
}

const MyComponent = {
  name:"MyComponent",
  setup(props, {emit}){
    emit("change", 1, 1)
    return ()=>{
      return //...
    }
  }
}

emit发射事件的本质是:通过事件名称去props对象数据中寻找对应的事件处理函数并执行。

function mountComponent(vnode, container, anchor){ 
  // 省略部分代码
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null
  }
  
  function emit(event, ...payload){
    // 如change -> onChange
    const eventName = `on${event[0].toUpperCase() + event.slice(1)}`;
    // 根据处理后的事件名称去props中寻找对应的事件处理函数
    const handler = instance.props[eventName];
    if(handler){
      handler(...payload);
    }else{
      console.error("事件不存在")
    }
  }
  
  const setupContext = { attrs, emit };
  
  //省略部分代码...
}

在上面代码中,其实就是在setupContext对象中添加emit方法,在emit函数被调用时,根据约定对事件名称便于在props数据对象中找到对应的事件处理函数。最终调用函数和传递参数,在解析props数据时需要对事件类型的props进行处理。

function resolveProps(options, propsData){
  const props = {};
  const attrs = {};
  for(const key in propsData){
    if(key in options || key.startWith("on")){
      props[key] = propsData[key]
    }else{
      attrs[key] = propsData[key]
    }
  }
  return [props, attrs]
}

5 . 插槽的工作原理与实现

插槽就是在组件中预留槽位,具体渲染内容由用户插入:

<template>
  <header><slot name="header"/></header>
  <div>
    <slot name="body"/>
  </div>
  <footer><slot name="footer"/></footer>
</template>

父组件中使用组件,通过插槽传入自定义内容:

<MyComponent>
  <template #header>
    <h1>我是标题</h1>
  </tmeplate>
  <template #body>
    <h1>我是内容</h1>
  </tmeplate>
  <template #footer>
    <h1>我是底部内容</h1>
  </tmeplate>
</MyComponent>

父组件的模板编译成渲染函数:

function render(){
  return {
    type:MyComponent,
    children:{
      hader(){
        return {
          type:"h1",
          chidlren:"我是标题"
        }
      },
      body(){
        return {
          type:"section",
          chidlren:"我是内容"
        }
      },
      footer(){
        return {
          type:"p",
          chidlren:"我是底部"
        }
      }
    }
  }
}

组件MyComponent模板编译成渲染函数:

function render(){
  return [
    {
      type:"header",
      chidlren:[this.$slots.header()]
    },{
      type:"body",
      chidlren:[this.$slots.body()]
    },{
      type:"footer",
      chidlren:[this.$slots.footer()]
    }
  ]
}

在上面代码中,看到渲染插槽内容的过程,就是调用插槽函数比不过渲染返回内容的过程。

function mountComponent(vnode, container, anchor){
  // 省略代码
  
  const slots = vnode.children || {}
  const setupContext = {attrs, emit, slots};
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null,
    slots
  }
  const renderContext = new Proxy(instance,{
    get(t, k, r){
      const {state, props, slots} = t;
      if(k === "$slots") return slots;
      //省略部分代码
    },
    set(t,k,v,r){
      //省略部分代码
    }
    //省略部分代码
}

其实,slots的实现就是将编译好的vnode.children作为slots对象,然后将slots对象添加到setupContext对象中。

6 . 注册生命周期

在Vue.js3中,部分组合式api是用来注册生命周期钩子函数的,在setup函数中调用onMounted函数即可注册onMounted生命周期钩子函数,并且多次调用就注册多个钩子函数。

import {onMounted} from "vue";

const MyComponent = {
  setup(){
    onMounted(()=>{
      //...
    });
    onMounted(()=>{
      //...
    });
  }
}

在组件初始化并执行组件的setup函数前,需要将currenrInstance变量设置为当前组件实例进行存储,再执行组件的setup函数,这样就可以通过currenrInstance获取当前正在被初始化的组件实例,从而将那些通过onMounted函数注册的钩子函数与组件实例关联。

let currentInstance = null;
function setCurrentInstance(instance){
  currentInstance = instance;
}

function mountComponent(vnode, container, anchor){
  //省略部分代码
  
  const instance = {
    state,
    props:shallowReactive(props),
    isMounted:false,
    subTree:null,
    slots,
    mounted:[]
  }
  //省略部分代码
  
  // setup
  const setupContext = {attrs, emit, slots};
  
  setCurrentInstance(instance);
  //执行setup
  const setupResult = setup(shallowReadonly(instance.props), setupContext);
  //重置组件实例
  setCurrentInstance(null);
  // 省略部分代码
  
  effect(()=>{
    const subTree = render.call(state, state);
    
   if(!instance.isMounted){
     //省略部分代码
     //遍历数组逐个执行
     instance.mounted && instance.mounted.forEach(hook=>hook.call(renderContext))
   }else{
     //省略部分代码
   }
   // 更新子树
   instance.subTree = subTree
  },{
    scheduler: queueJob
  })
}

function onMounted(fn){
  if(currentInstance){
    currentInstance.mounted.push(fn);
  }else{
    console.error("onMounted函数只能在setup函数中使用")
  }
}

除了onMounted钩子函数外,其他钩子函数原理同上。

7.写在最后

在本文中介绍了:props与组件的被动更新、setup函数的作用与实现、组件事件与emit的实现、插槽的工作原理与实现以及注册生命周期等。

更多:https://mp.weixin.qq.com/mp/appmsgalbum?action=getalbum&album_id=2338360203559174145
来源: 前端一码平川


链接: https://www.fly63.com/article/detial/11388

Vuetify基于vue2.0,为移动而生的组件框架

Vuetify 支持SSR(服务端渲染),SPA(单页应用程序),PWA(渐进式Web应用程序)和标准HTML页面。 Vuetify是一个渐进式的框架,试图推动前端开发发展到一个新的水平。

Vue中插槽的作用_Vue组件插槽的使用以及调用组件内的方法

通过给组件传递参数, 可以让组件变得更加可扩展, 组件内使用props接收参数,slot的使用就像它的名字一样, 在组件内定义一块空间。在组件外, 我们可以往插槽里填入任何元素。slot-scope的作用就是把组件内的数据带出来

react 函数子组件(Function ad Child Component)

函数子组件(FaCC )与高阶组件做的事情很相似, 都是对原来的组件进行了加强,类似装饰者。FaCC,利用了react中children可以是任何元素,包括函数的特性,那么到底是如何进行增强呢?

Vue和React组件之间的传值方式

在现代的三大框架中,其中两个Vue和React框架,组件间传值方式有哪些?组件间的传值是灵活的,可以有多种途径,父子组件同样可以使用EventBus,Vuex或者Redux

vue.js自定义组件directives

自定义指令:以v开头,如:v-mybind。bind的作用是定义一个在绑定时执行一次的初始化动作,观察bind函数,它将指令绑定的DOM作为一个参数,在函数体中,直接操作DOM节点为input赋值。

vue中prop属性传值解析

prop的定义:在没有状态管理机制的时候,prop属性是组件之间主要的通信方式,prop属性其实是一个对象,在这个对象里可以定义一些数据,而这些数据可以通过父组件传递给子组件。 prop属性中可以定义属性的类型,也可以定义属性的初始值。

Web组件简介

Web组件由三个独立的技术组成:自定义元素。很简单,这些是完全有效的HTML元素,包含使用一组JavaScript API制作的自定义模板,行为和标记名称(例如,<one-dialog>)。

web组件调用其他web资源

web组件可以直接或间接的调用其他web资源。一个web组件通过内嵌返回客户端内容的另一个web资源的url来间接调用其他web资源。在执行时,一个web资源通过包含另一个资源的内容或者转发请求到另一个资源直接调用。

vue中如何实现的自定义按钮

在实际开发项目中,有时我们会用到自定义按钮;因为一个项目中,众多的页面,为了统一风格,我们会重复用到很多相同或相似的按钮,这时候,自定义按钮组件就派上了大用场,我们把定义好的按钮组件导出,在全局引用,就可以在其他组件随意使用啦,这样可以大幅度的提高我们的工作效率。

Vue子组件调用父组件的方法

Vue中子组件调用父组件的方法,这里有三种方法提供参考,第一种方法是直接在子组件中通过this.$parent.event来调用父组件的方法,第二种方法是在子组件里用$emit向父组件触发一个事件,父组件监听这个事件就行了。

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!