vue源码学习

256 阅读9分钟

前言

可能每个从事前端的人,都需要从入门实习生转换为应届小白,再慢慢变成大佬,每一位大佬都是经历了无数的历练。vue目前是前端使用频率较高的一套前端mvvm框架之一,很多公司都在使用vue,作为一个vuer(vue使用者),学习源码是真正点亮vue技术栈的必经之路。只有具备一定的前端基础,读起源码才会显得毫不费劲。

vue源码阅读知识储备

读源码之前,先学习一些必备的知识,由浅入深,深入浅出,慢慢接近源码~~~~~~

1、数据驱动

1.1 vue使用步骤

   1. 编写页面模板 
        1、直接在HTML中写标签
        2、使用template
        3、使用但文件<template/>
    2. 创建vue实例
        在vue构造函数中提供:data、methods、computed、watch
    3. 将vue挂载到页面中(mount)

1.2 vue的执行流程

      1、vue获得模板,模板中有坑位
      2、利用vue构造函数中所提供的数据来填坑,得到可以在页面中显示的”标签了“
      3、将标签替换页面中原来有坑的标签
      

1.3 一个简单的vueDemo

   <!-- 第一步:写模板 -->
  <div id="root">
      <p>{{name}}</p>
      <p>{{message}}</p>
  </div>
  <script>
     console.log(root)  //这里还没有被vue进行操作
      // 第二步:创建实例
      let  app=new Vue({
          el:"#root",
          data:{
              name:"vina",
              message:"前端开发工程师"
          }
      })
      // 第三步:挂载,vue.js帮我们实现了挂载
    console.log(root) 
  </script>

1.4 模拟vue内部实现数据驱动模板

vue利用我们提供的数据和页面中的模板生成了一个新的HTML标签,替换到页面中放置模板的位置,如果我们不用vue该怎么实现上面的功能呢

<div id="root">
       <p>{{name}}</p>
       <p>{{message}}</p>
   </div>
   <script>
       // 第一步,我们要拿到模板
       // 第二步,我们要拿到数据
       // 第三步:将数据和模板结合,得到的是HTML元素(DOM元素)

      let rkuohao=/\{\{(.+?)\}\}/g    //.+匹配任何字符,?取值贪婪,可能有多个双括号
     // 第一步,我们要拿到模板
      let tmpNode=document.querySelector("#root") 
       // 第二步,我们要拿到数据
      let data={
           name:'一个新的name',
           message:'一个消息'
       }
   //  第三步:将数据和模板结合,得到的是HTML元素(DOM元素)
      function compiler(template,data){
           let childNodes=template.childNodes;
           for(let i=0;i<childNodes.length;i++){
               // 我们需要判断子元素是不是文本节点,可能有插值,
               let type=childNodes[i].nodeType;   //1:元素   3:文本节点
               if(type==3){
                   //文本节点,判断里面是否{{}}插值
                   let txt=childNodes[i].nodeValue  //该节点只有文本节点才有意义
                   console.log("txt",txt) 
                   //使用data替换{{}}中的值
                   txt= txt.replace(rkuohao,function(_,g ){
                        let key=g.trim()  
                        let value=data[key]
                        return value
                   })
                   // 注意txt现在和DOM元素是没有任何关系的,所以我们要吧txt加进去
                   childNodes[i].nodeValue=txt
               }
               else if(type==1){
                   //元素,我们就需要考虑有没有子元素,是否需要将其子元素判断是否要插值
                   compiler(childNodes[i],data)
               }
           }
      }
      let generateNode=tmpNode.cloneNode(true)  //这里是DOM元素,可以这么用
      compiler(generateNode,data)
      root.parentNode.replaceChild(generateNode,root)
   </script>

思路分析

上面的思路就是,第一步,我们要拿到模板。第二步,我们要拿到数据。第三步:将数据和模板结合,得到的是HTML元素(DOM元素),前两步比较简单,最重要的是第三部怎么结合。这里我们用的是真实的DOM,vue源码中是DOM=>字符串模板--->Vnode---->真正的DOM。先不管源码,我们先最简化的完成这个功能,我们详解第三步: 定义function compiler(template,data){} let childNodes=template.childNodes; 我们获取到模板的子节点。我们需要判断子元素是不是文本节点,可能有插值,let type=childNodes[i].nodeType; type是1的话就是元素,3的话就是文本,如果是1 我们就回调compiler这个方法继续去找type等于3的节点。只有文本节点才有意义。我们取出文本节点中的文本let txt=childNodes[i].nodeValue 利用正则表达式替换{{}}插值。替换完成后我们把替换的txt插入到刚刚的文本节点对应的文本处hildNodes[i].nodeValue=txt。以上就完成了将数据和模板结合。

然后我们将原本的模板let generateNode=tmpNode.cloneNode(true) 克隆一份,然后调用刚刚 将数据和模板结合的方法 compiler(generateNode,data), 最后将替换后generateNode的放入页面中generateNode去替换rootroot.parentNode.replaceChild(generateNode,root)。 我们可以打印generateNode、tmpNode对比一下,替换前后的样子。


上面讲述了怎么去模拟vue实现数据驱动模型的过程,但是这个还是不能完全实现vue底层所做的事情,我们这里的模板是极简模式,而且vue使用的虚拟DOM,而我们这里用的真实的DOM,我们上面的只考虑了单属性({{name}}),而vue中大量使用层级({{child.name.firstName}})我们的操作只是一步一步操作,代码没有整合。下面开始进行优化。

改进

  • 第一,vue使用的虚拟DOM,而我们这里用的真实的DOM
  • 第二,我们上面的只考虑了单属性({{name}}),而vue中大量使用层级({{child.name.firstName}})
  • 第三,我们的操作只是一步一步操作,代码没有整合

1.5 改进数据驱动模板

改进1(代码整合)

我们先解决代码整合问题,抽取出JGVue,给JGVue函数添加原型方法。

         function compiler(template,data){
            let childNodes=template.childNodes;
            for(let i=0;i<childNodes.length;i++){
                let type=childNodes[i].nodeType;   //1:元素   3:文本节点
                if(type==3){
                    let txt=childNodes[i].nodeValue  //该节点只有文本节点才有意义
                    console.log("txt",txt)   //text {{name}}   text {{message}}
                    txt= txt.replace(rkuohao,function(_,g ){
                         let key=g.trim()  //g就是写在双括号里面的东西
                         let value=data[key]
                         return value
                    })
                    console.log("txt",txt)
                    childNodes[i].nodeValue=txt

                }
                else if(type==1){
                    compiler(childNodes[i],data)
                }
            }
       }
       
        function  JGVue(option){
            // 我们有一个习惯,内部数据使用下划线,只读数据使用$开头
            this._data=option.data;
            this._el=option.el;
            // 准备工作(获得准备模板、数据)
           this.$el= this._templateDOM=document.querySelector(this._el)
           this._parent=this._templateDOM.parentNode;//存父元素
           //渲染工作
           this.render()
        }
        
        // 给JGVue函数添加原型方法,将模板结合数据得到HTML加到原型中(拆解为compiler)
        JGVue.prototype.render=function(){
              this.compiler()

        }
        //compiler把DOM和数据结合(编译)
        JGVue.prototype.compiler=function(tmpNode){
            let realHTMLDOM=this._templateDOM.cloneNode(true)  //用模板拷贝得到一个准DOM
            compiler(realHTMLDOM,this._data)
            this.update(realHTMLDOM)
        }
        // 将DOM元素加入页面中(更新)
        JGVue.prototype.update=function(real){
            //   把real替换到页面  这里不用$el是因为$el会被替换掉,所以我们存下他的父元素,通过父元素去找里面的
            this._parent.replaceChild(real,document.querySelector('#root'))

        }        
        // 想一想怎么用?
        let app=new JGVue({
            el:"#root",
            data:{
                name:'jim',
                message:'info'

            }
        })

image.png

上述主要是将之前的方法抽取成构造函数对代码进行整合。结构上更加清晰。

改进2(大量使用层级)

image.png

上述数据没有层级嵌套的时候我们直接根据key获取数据的值,当出现层级嵌套,如何获取数据呢?

image.png

image.png

下面定义的方法getValueByPath解决层级问题

image.png

上面虽然解决了层级获取值的问题,但是我们的模板是不会变得,而我们的数据常常在变化的,所以在vue里面做了一个非常有技巧的事情,函数柯里化。 特点:

  • 模板是不变的
  • 数据是变化的

改良

image.png

createGetValueByPath是在vue编译我们的模板的时候就生成了,这个函数在任何地方都会被调用。 Vue是把我们模板转换成抽象语法书,然后利用抽象语法树生成虚拟DOM,然后利用虚拟DOM去渲染页面。所以这里做的优化,可以减少函数的调用。

接下来把代码放入数据驱动模板模拟代码中(这里先不使用改良版的,先解决层级问题)

image.png

改进3(使用虚拟DOM)

目标

  • 如何把真正DOM转换成虚拟DOM
  • 如何把虚拟DOM放到页面

思路与深拷贝类似。(深度遍历节点)

为什么要用虚拟DOM?

 因为我们直接在页面中操作DOM,真正的DOM可能会带来页面的刷新,还有内存的控制,很消耗性能。使用虚拟DOM,所有的操作都在内存里,只要把虚拟DOM的处理完成了,只要更新在页面中就可以,只需要更新一次。
  • < div/> ====>{tag:'div'}
  • 文本节点 =====>{tag:undefined,value}
  • < div title="1" class="c"/>=>{tag:'div',data:{title:'1',class:'c'}}
  • < div>< div/>< /div>=>{tag:'div',children:[{tag:'div'}]}
 // 下面我们使用class语法
      class VNode{
          // type 1 元素 3 文本
          // 参数(标签名,描述属性,描述文本,type),实际DOM还有elm,这里暂不考虑
          constructor(tag,data,value,type){
              this.tag=tag&&tag.toLowerCase()  //小写化
              this.data=data;   
              this.value=value
              this.type=type;
              this.children=[]  
          }
          // 往children追加子元素
          appendChild(vnode){
               this.children.push(vnode)
          }
      }
      //  使用递归来遍历DOM元素,生成虚拟DOM
      // vue里面的源码使用的是栈结构,使用栈存储父元素来实现递归生成
      function getVNode(node){ //传递真正的node
          let nodeType=node.nodeType;   //根据nodeType区分是元素还是文本
          let _vnode=null  
          if(nodeType===1){
              //元素
              let nodeName=node.nodeName
              let attrs=node.attributes  //attributes返回所有属性构成的数组(伪数组)
              let _attrObj={}  //把属性包装成
              for(let i=0;i<attrs.length;i++){  //attrs[i]属性节点(nodeType==2)
                  _attrObj[attrs[i].nodeName]=attrs[i].nodeValue
              }
              _vnode=new VNode(nodeName,_attrObj,undefined,nodeType);
              console.log("_vnode",_vnode)
              // 考虑node(真正的DOM)的子元素
              let childNodes=node.childNodes;
              for(let i=0;i<childNodes.length;i++){
                  _vnode.appendChild(getVNode(childNodes[i]))  //递归
              }
                  
          }else if(nodeType===3){
               _vnode=new VNode(undefined,undefined,node.nodeValue,nodeType)
          }
          return _vnode
      }
       let root=document.querySelector("#root")
       let vroot= getVNode(root)
       console.log(vroot)
  // ---------------------------第二种算法---------------------------
          function parseVNode(vnode){
           console.log(vnode)
           let _node=null
          //  创建真实的DOM
          let type=vnode.type
          if(type==3){
              // 说明他就是一个文本节点,直接创建
              return document.createTextNode(vnode.value)  //创建文本节点
          }else if(type==1){
              //元素节点
              _node=document.createElement(vnode.tag)

              //属性
              let data=vnode.data  //现在这个data是键值对
              Object.keys(data).forEach((key)=>{
                  let attrName=key;
                  let attrValue=data[key]
                  _node.setAttribute(attrName,attrValue)  //绑定属性
              })
              //子元素
              let children=vnode.children
              children.forEach(subnode=>{
                  // subnode是虚拟DOM,递归转换子元素,然后加入到_node
                  _node.appendChild(parseVNode(subnode))

              })
              console.log(_node)
              return _node
          }

       }
       

总结:

柯里化:一个函数原本有多个参数。只传入一个参数,生成一个新函数,由新函数来接收剩下的参数来运行得到的结构。

偏函数:一个函数原本有多个参数。只传入一部分参数,生成一个新函数,由新函数来接收剩下的参数来运行得到的结构。

1.6 函数柯里化

为什么要使用柯里化?

为了提神性能,使用柯里化可以缓存一部分能力。

使用案例来说明

  • 判断元素
  • 虚拟DOM的render方法

Vue本质上使用HRTML的字符串作为模板,将字符串的模板转换成AST,再转换成VNode

  • 模板----->AST
  • AST------>VNode
  • VNode----->DOM

哪一个阶段最消耗性能?模板----->AST(需要对字符串进行解析)

举个例子: let s="1+2*(3+4)"写程序来解析这个表达式,得到结果?

 我们需要考虑一般化,我们一般会将这个表达式转换成“波兰式”表达式,然后使用栈结构来运算。

第一个例子:在Vue中每一个标签可以是真正的HTML标签,也可以是自定义标签,怎么区分?

  在vue源码中,将所有可用的HTML标签已经存起来了。
  
  简化:假设这里只考虑几个标签

 let tag='div,p,a,img,ul,li'.split(","),现在我需要一个函数,判断一个标签名是否为内置标签
   function isHTMLTag(tagName){
       tagName=tagName.toLowerCase()
       for(let i=0;i<...){
          if(tagName===ta877g[i])  return true
       }
       //这里可以改写成indexOf
      // if(tag.indexOf(tagName)>1)  return true
        return false
   }

模板是任意去编写,可以写的很简单,也可以写的很复杂,indexOf内部也是要循环的,如果6个内置标签,而模板中有10个标签需要去判断,那么需要执行60次循环。vue采用了makeMap函数。

  let tags='div,p,a,img,ul,li'.split(',')
        function makeMap(keys){
            let set={}; //集合
              keys.forEach(key => set[key]=true);  //把所有标签名作为key,值为true
              
            return function(tagName){
                return !!set[tagName.toLowerCase()]  //返回布尔值


            }
          }
        let isHTMLTag=makeMap(tags)  //返回里面的函数
        isHTMLTag('li')

假设又10哥标签需要判断,还有没有循环存在?isHTMLTag调用的是内部的function,不存在循环了,性能被一点点的优化了。这里使用柯里化保存一部分数据。

第一个例子:虚拟DOM 的render方法

思考一个问题,我们vue的项目,模板转换成抽象语法树(模板----->AST),需要执行几次?

     1、页面一开始加载需要渲染
     2、每一个属性数据发生变化时要渲染
     3、watch,computed等等。

回答:我们昨天写的代码,每次需要渲染的时候模板就会被解析一次(这里我们简化了解析方法)。模板是不会变的,抽象语法树也不会变,我们的render作用是将我们的虚拟DOM 转换为我们真正的DOM,加入页面中。

--- 虚拟DOM可以降级理解为抽象语法树AST

--- 一个项目运行时模板是不会变的,就表示AST是不会变的

处理:我们可以将代码进行优化,将虚拟DOM缓存起来,然后生成一个函数,函数只需要传入数据就可以得到真正的DOM
      

接下来我们任务就是把我们昨天代码推倒重来,重新转换成具有缓存功能的。

1.7 真正靠近源码

我们现在先写一个轻量级的VUE,然后再去看VUE源代码,把思想先形成。

说明:在真正的Vue中,使用了二次提交的设计结构

  • 1、在我们页面中的DOM和虚拟DOM是一一对应的关系(如下图)
  • 2、现有AST和数据生成Vnode(新的VNode)
  • 3、将新旧Vnode比较(diff算法,diff算法是很多函数组成的)

解释下图:在我们页面中的DOM和虚拟DOM是一一对应的关系,我们的页面展示的HTML标签就是我们真正DOM,真正的DOM背后又一个一一对应的关系,有一个虚拟的DOM,是实实在在存在的,每次在改变数据的时候都会生成一个新的虚拟DOM(Vnode),只要数据发生变化,就有新的Vnode,是一个新数据的Vnode,然后我们会把新的Vnode和页面中的Vnode进行比较,就是我们说的diff算法,那些不同就更新过去。目的就是更新,在更新到Vnode的时候伴随更新的行为也就更新了我们真正的DOM。*

image.png

分析:将上图思想拆分成一个个函数去完成相应的功能,这几个函数的职责范围在下图中体现,createRenderFn生成虚拟DOM,render将带有坑的vnode和数据结合,得到填充数据的vnode,去模拟AST->Vnode。最后是Update。

我们数据和我们页面模板得到抽象语法树,在我们代码中,我们没有用抽象语法树而是用虚拟DOM来描述的。抽象语法树在render函数里面会结合我们数据生成我们的虚拟DOM,我们在代码中是利用带坑的虚拟DOM、和填充数据的虚拟DOM来模拟这两个行为,然后利用update方法,把我们抽象语法树渲染到我们的页面中。

image.png

<!--
 * @Author: Vina
 * @LastEditors: Vina
 * @LastEditTime: 2021-04-23 19:54:32
-->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div id="root">
        <div class="c1">
            <div title="tt1">{{name}}</div>
        </div>
    </div>
    <!--  -->
    <script>
        let rkuohao = /\{\{(.+?)\}\}/g    //.+匹配任何字符,?取值贪婪,可能有多个双括号
        //   我们要解决一个问题:
        //  使用XXX.yyy.zzz可以来访问某一个对象
        //   就是用字符串、路径来访问对象的成员
        function getValueByPath(obj, path) {
            let paths = path.split('.');   //[XXX,yyy,zzz]
            //先取得obj.xxx再取得结果中的yyy再取得结果中的zzz
            let res = null
            res = obj;
            let prop;
            while (prop = paths.shift()) {
                // 每次取最前面的给prop
                res = res[prop]
            }
            return res
        }
        // 虚拟DOM构造函数
        class VNode {
            // type 1 元素 3 文本
            // 参数(标签名,描述属性,描述文本,type),实际DOM还有elm,这里暂不考虑
            constructor(tag, data, value, type) {
                this.tag = tag && tag.toLowerCase()  //小写化
                this.data = data;
                this.value = value
                this.type = type;
                this.children = []
            }
            // 往children追加子元素
            appendChild(vnode) {
                this.children.push(vnode)
            }
        }
        //  有HTML DOM去生成虚拟DOM:将这个函数当作complier函数(就是编译成抽象语法书的函数)
        function getVNode(node) { //传递真正的node
            let nodeType = node.nodeType;   //根据nodeType区分是元素还是文本
            let _vnode = null
            if (nodeType === 1) {
                //元素
                let nodeName = node.nodeName
                let attrs = node.attributes  //attributes返回所有属性构成的数组(伪数组)
                let _attrObj = {}  //把属性包装成
                for (let i = 0; i < attrs.length; i++) {  //attrs[i]属性节点(nodeType==2)
                    _attrObj[attrs[i].nodeName] = attrs[i].nodeValue
                }
                _vnode = new VNode(nodeName, _attrObj, undefined, nodeType);
                console.log("_vnode", _vnode)
                // 考虑node(真正的DOM)的子元素
                let childNodes = node.childNodes;
                for (let i = 0; i < childNodes.length; i++) {
                    _vnode.appendChild(getVNode(childNodes[i]))  //递归
                }


            } else if (nodeType === 3) {
                _vnode = new VNode(undefined, undefined, node.nodeValue, nodeType)
            }
            return _vnode
        }

        function combine(vnode, data) {
            console.log("Vnode@@@@@", vnode)
            // 将带有坑的vnode和数据结合,得到填充数据的vnode,去模拟AST->Vnode
            let _type = vnode.type
            let _data = vnode.data
            let _value = vnode.value
            let _tag = vnode.tag;
            let _children = vnode.children;
            let _vnode = null;
            if (_type === 3) {

                // 文本节点,需要正则表达式
                // 对文本处理
                _value = _value.replace(rkuohao, function (_, g) {
                    // 解析数据
                    return getValueByPath(data, g.trim())
                })
                //生成节点
                _vnode = new VNode(_tag, _data, _value, _type)

            } else if (_type === 1) {
                // 元素节点
                _vnode = new VNode(_tag, _data, _value, _type);
                // 递归处理子元素
                _children.forEach(_subvnode => {
                    _vnode.appendChild(combine(_subvnode, data))
                })
            }
            return _vnode
        }

        function JGVue(option) {
            this._data = option.data;
            // this._template = option.el;   //vue中是字符串,这里是DOM(这里简化了操作)
            this._template = document.querySelector(option.el)


            this.mount() //挂载
        }

        //mount调用mountComponent,这么写就是为了靠近源码
        JGVue.prototype.mount = function () {
            // 需要提高一个render方法,render作用:生成虚拟DOM
            this.render = this.createRenderFn() //要求能够缓存虚拟DOM的能力

            this.mountComponent();
        }

        JGVue.prototype.mountComponent = function () {
            // 执行mountComponent()函数
            //
            let mount = () => {
                this.update(this.render())  //把虚拟到渲染到页面上
            }
            mount.call(this);  //调用mount,本质上应该交给watcher来调用,还没讲到watcher

        }
        //createRenderFn就是用来生成render函数,同时缓存我们的抽象语法树。render是利用抽象语法树和数据结构生成虚拟DOM
        JGVue.prototype.createRenderFn = function () {
            // 这里是生成render函数,目的是缓存我们的抽象语法树,我们使用虚拟Dom来模拟
            //将ASD+data=>Vnode   vue里面实现逻辑,此处我们简化,我们直接用带坑的vnode,就不解释ast的语法了
            // 缓存ast
            let ast = getVNode(this._template);//这时候拿到虚拟DOM了(我们搞一个带坑的虚拟DOM当作抽象语法树)
            return function render() {
                //将带坑的转换成真正的带数据的Vnode

                let _tmp = combine(ast, this._data) //有数据了
                return _tmp

            }

        }
        // 将虚拟DOM渲染到页面中,我们的diff算法就在这里
        //说明:在真正的Vue中,使用了二次提交的设计结构 
        /***
          1、在我们页面中的DOM和虚拟DOM是一一对应的关系
          2、现有AST和数据生成Vnode(新的VNode)
          3、将新旧Vnode比较(diff算法,diff算法是很多函数组成的)
        
        ***/
        JGVue.prototype.update = function () {
            // 这里先简化,直接生成HTML DOM replaceChild到页面中
        }

        let app = new JGVue({
            el: "#root",
            data: {
                name: "vina",
                age: 19,
                gender: "男"
            }
        })
    </script>
</body>

</html>

后面开始做响应式。什么是响应式呢,如下:

  let app = new JGVue({
            el: "#root",
            data: {
                name: "vina",
                age: 19,
                gender: "男"
            }
        })

        app.name = "李四";//这个赋值一做完,页面就更新,这就是响应式。双向绑定是建立在响应式基础之上的

2、响应式原理

要解决什么问题?

  • 我们在使用vue的时候,赋值属性获得属性都是直接使用的vue实例
  • 我们在set属性值的时候,页面要更新

技巧:

Object.definePropty(对象,"设置什么属性名",{
   writeable,
   configable,
   enumberable,  用来控制属性是否可枚举
   set
   get

})

例子:

    var o = {};
        //给o提供属性
        o.name = "张三";
        //等价于
        Object.defineProperty(o, 'age', {
            configurable: true,
            writable: true,
            enumerable: false, //可枚举
            value: 19
        })
        // 在控制台打印一下,age是灰色了,但是可以获取值,可以设置值
        for (var k in o) {
            console.log(k)
        }

image.png