前言
可能每个从事前端的人,都需要从入门实习生转换为应届小白,再慢慢变成大佬,每一位大佬都是经历了无数的历练。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'
}
})
上述主要是将之前的方法抽取成构造函数对代码进行整合。结构上更加清晰。
改进2(大量使用层级)
上述数据没有层级嵌套的时候我们直接根据key获取数据的值,当出现层级嵌套,如何获取数据呢?
下面定义的方法getValueByPath解决层级问题
上面虽然解决了层级获取值的问题,但是我们的模板是不会变得,而我们的数据常常在变化的,所以在vue里面做了一个非常有技巧的事情,函数柯里化。 特点:
- 模板是不变的
- 数据是变化的
改良
createGetValueByPath是在vue编译我们的模板的时候就生成了,这个函数在任何地方都会被调用。 Vue是把我们模板转换成抽象语法书,然后利用抽象语法树生成虚拟DOM,然后利用虚拟DOM去渲染页面。所以这里做的优化,可以减少函数的调用。
接下来把代码放入数据驱动模板模拟代码中(这里先不使用改良版的,先解决层级问题)
改进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。*
分析:将上图思想拆分成一个个函数去完成相应的功能,这几个函数的职责范围在下图中体现,createRenderFn生成虚拟DOM,render将带有坑的vnode和数据结合,得到填充数据的vnode,去模拟AST->Vnode。最后是Update。
我们数据和我们页面模板得到抽象语法树,在我们代码中,我们没有用抽象语法树而是用虚拟DOM来描述的。抽象语法树在render函数里面会结合我们数据生成我们的虚拟DOM,我们在代码中是利用带坑的虚拟DOM、和填充数据的虚拟DOM来模拟这两个行为,然后利用update方法,把我们抽象语法树渲染到我们的页面中。
<!--
* @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)
}