文章目录
前言
深度解析vue2源码是如何实现数据双向绑定
我们先实现一个简单的vue实例 demo.html
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
</head>
<body>
<div id="app" :title="user.name">
<input type="text" v-model="user.name" />
{
{
user.age}}
<div>{
{
user.name}}{
{
user.age}}</div>
<div>aabb</div>
</div>
</body>
<script>
let vm = new Vue({
el: "#app",
data() {
return {
user: {
name: "whs",
age: 10,
},
};
},
});
</script>
</html>
其中v-model 和{
{}}表达式都可以正常渲染,我们创建自己的myvue.js 文件, 在demo.html中使用myvue.js替换官方的vue文件<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
1.创建vue对象,获取目标节点
<html lang="en">
<head>
<script src="myvue.js"></script>
</head>
<body>
<div id="app">
<input type="text" v-model="user.name">
<div>{
{
user.name}}</div>
<div>{
{
user.age}}</div>
</div>
</body>
<script>
let vm = new Vue({
el:"#app",
data(){
return {
user:{
name:"whs",
age:10
}
}
}
})
</script>
</html>
创建myvue.js文件
定义vue类,获取构造参数
//基础类
class Vue{
constructor (options){
// this.$el this.$data
this.$el = options.el
this.$data = options.data()
if(this.$el){
new Compiler(this.$el,this)
}
}
}
为了把vue中data的数据渲染的vue作用域中的{
{}}和v-model
上,我们创建一个编译器用来渲染页面,其次我们需要获取vue的作用域,也就是目标节点
class Compiler {
constructor(el,vm){
this.el = this.isElementNode(el)? el:document.querySelector(el)
console.log(this.el);
}
//判断是不是元素节点
isElementNode(node){
return node.nodeType===1
}
}
我们的模板上有多处使用data数据的地方,比如user.name和user.age
如果识别一个节点数据就渲染一个节点,多次渲染会导致页面多次重排重绘,vue使用虚拟dom节点将整个模板都准备好后统一渲染一次页面
我们现在需要做的是
-
删除页面上的模板元素dom节点
-
根据模板数据和data数据重新创建dom节点
-
将新的dom节点放回到页面上
2.替换页面上的模板元素dom节点
因为节点上还有我们需要的v-model等表达式,我们需要记录节点上的数据,window提供了一种文档碎片 fragment ,是一种内存中的节点。其中提供的appendChild(dom)在给fragment添加child的同时会把参数上dom从页面上移除掉。
反之构建好的fragment后,我们的this.el的dom节点可以使用appendChild(fragment)将编译好的节点渲染出来
class Compiler {
constructor(el,vm){
this.el = this.isElementNode(el)? el:document.querySelector(el)
this.vm = vm
//将dom节点转换为内存中的数据
let fragment = this.node2fragment(this.el)
//替换碎片中的数据
//编译模板
//将准备好的数据放回页面
this.el.appendChild(fragment)
}
node2fragment(node){
let fragment = document.createDocumentFragment()
let firstChild;
while(firstChild=node.firstChild){
fragment.appendChild(firstChild)
}
return fragment
}
//判断是不是元素节点
isElementNode(node){
return node.nodeType===1
}
}
3.编译模板
3.1 获取需要编译的节点
根据模板数据和data数据重新创建dom节点,我们创建一个compile
方法来编译模板,将刚才创建的fragment的各个子节点获取,在下文中的log中我们知道,页面上除了我们定义的<div>
节点以外还有#text
节点,不同的节点我们使用不同的方法来渲染compileElement
和compileText
compile(fragment){
let childNode =fragment.childNodes;
console.log(childNode);
[...childNode].forEach(node=>{
if(this.isElementNode(node)){
this.compileElement(node)
}else{
this.compileText(node)
}
})
}
在compileElement
方法中我们接受的是dom节点,但是我们只需要渲染绑定v-model的节点,我们使用attributes获取节点属性,isDirective
判断一下属性是不是v-
开头,(除了v-model外还有很多vue的指令,本文只实现v-model)这里在name
上我们获得了v-model,在value
上获得了绑定的user.name
isDirective(attrName){
return attrName.startsWith("v-")
}
//编译元素
compileElement(node){
let attributes = node.attributes;
[...attributes].forEach(attr => {
let {
name,value} = attr
if(this.isDirective(name)){
console.log(name,value,node);
}
});
}
然后我们实现对应的compileText
来获取同样的数据,在#text
节点上我们使用{
{user.age}}{
{user.name}}
这种表达式,我们使用textContent获取文本内容
//编译文本
compileText(node){
let text = node.textContent
console.log(text);
}
我们会发现打印的只有user.age并没有user.name,是因为user.name是div
中的子文本节点,我们需要在刚才的compile
方法中实现递归遍历所有子节点,如下图添加this.compile(node)
//编译内存中的dom
compile(fragment){
let childNode =fragment.childNodes;
[...childNode].forEach(node=>{
if(this.isElementNode(node)){
this.compileElement(node)
//递归遍历子节点
this.compile(node)//追加代码
}else{
this.compileText(node)
}
})
}
再使用正则表达式过滤到需要渲染的text节点
//编译文本
compileText(node){
let textContent = node.textContent
if(/\{\{(.+?)\}\}/.test(textContent)){
console.log('textContent-filter',textContent);
}
}
接下来我们来分别渲染v-model
和{
{}}
3.2 渲染v-model
我们创建一个CompilerUtils
工具包来保存各种渲染方法,首先是v-model
对应的渲染方法
1.我们在compileElement 中已经获取到 node
节点,v-model绑定的表达式user.name的expr
,还有当前的this.vm
三个参数
2.页面上是input
元素,我们直接使用dom
元素的value
属性修改,我们需要两个参数modelUpdater(node, value)
CompilerUtils = {
updater: {
modelUpdater(node, value) {
node