/vue-analysis

vue mvvm原理/dom-diff原理/vue-router原理

Primary LanguageJavaScript

Vue的实现原理分析

直接去分析源码意义不大

这里,主要是为了单独实现Vue中的每个功能

自己动手实现一遍会更加理解Vue

1. 实现MVVM

如何去实现一个MVVM框架呢?MVVM的实际意义就是数据的双向绑定,我们可以将数据跟视图绑定在一起,当数据变化的时候去通知视图变化,当视图变化的时候,同时也可以去影响数据的变化

实现数据的双向绑定是通过 编译模板 + 数据劫持 + 发布订阅这三个单独的功能组合到一起实现的

下面我们来分别看一下这几个功能的简单介绍

编译模板

编译模板实际就是把真实的dom获取之后,编译其中的指令和{{}}模板语法

class Compile{
    constructor(el,vm){
        //判断他是不是一个元素节点,如果不是我们就找到这个元素节点
        this.el = this.isElementNode(el)?el:document.querySelector(el);
        this.vm = vm; 
        if(this.el){
            //1.先把真实的dom移入到内存中
            let fragment = this.node2fragment(this.el);
            //2.编译==> 提取想要的元素节点 v-model 和文本节点 {{}}
            this.compile(fragment);
            //3.把编译好的fragment塞会页面中去
            this.el.appendChild(fragment);
        }
    }
    //专门写一些辅助的方法
    isElementNode(node){
        return node.nodeType === 1;
    }
    isDirective(name){
        return name.includes('v-');
    }


    //核心方法
    node2fragment(el){
        //需要将el中的内容全部放到内存中
        let fragment = document.createDocumentFragment();
        let firstChild;
        //循环取出第一个子元素
        while(firstChild = el.firstChild){
            fragment.appendChild(firstChild);
        }
        return fragment;//内存中的节点
    }
    //编译元素
    compileElement(node){
        //取出所有标签元素上的属性
        let attrs = node.attributes;
        Array.from(attrs).forEach(attr=>{
            //判断属性名字是不是包含v-
            let attrName = attr.name;
            if(this.isDirective(attrName)){
                //如果我们的属性带有v-指令的话,就是需要解析的指令
                let expr = attr.value;
                //expr是我们的指令的值,例如v-model="message"
                //expr = message
                //获取到指令的类型,例如model
                let [,type] = attrName.split('-');
                //node是我们的标签,vm是我们的实例,expr是v-model的值
                CompileUtil[type](node,this.vm,expr);
            }
        })
    }
    //编译文本
    compileText(node){
        //带{{}}
        let expr = node.textContent;
        let reg = /\{\{([^}]+)\}\}/g;
        if(reg.test(expr)){
            CompileUtil['text'](node,this.vm,expr);
        }
    }
    //编译我们的文档碎片,将它进行替换
    compile(fragment){
        //这里拿到的是我们的dom第一层的所有元素
        let childNodes = fragment.childNodes;
        Array.from(childNodes).forEach(node=>{
            //所有的节点(只是第一层)
            if(this.isElementNode(node)){
                //这里需要编译元素
                this.compileElement(node);
                //如果是元素节点的话,再递归去调用
                //例如<div>{{message}}</div>
                this.compile(node);
            }else{
                //是文本节点
                //这里需要编译文本
                this.compileText(node);
            }
        })
    }
}
CompileUtil = {
    getVal(vm,expr){
        expr = expr.split('.');
        return expr.reduce((prev,next)=>{
            return prev[next]; 
        },vm.$data);
    },
    //这一步主要是为了替换{{}},然后拿到值之后再去取值
    getTextVal(vm,expr){
        return expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            return this.getVal(vm,arguments[1]);
        });
    },
    //编译文本的
    //expr可能是{{message.a.b}},也可能是{{a}} {{b}} {{c}}
    text(node,vm,expr){
        //文本处理
        let updateFn = this.update['updateText'];
        //先把括号去掉后再循环取到data中的值
        //这时候,所有带有{{}}的值都正确得到了替换,得到了新的expr
        let value = this.getTextVal(vm,expr); 
        //给所有的数据都加上一个观察者模式,所以这里要循环
        expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
            new Watcher(vm,arguments[1],(newValue)=>{
                updateFn && updateFn(node,this.getTextVal(vm,expr));
            });
        });  
        //将新的expr的值插入到节点的内容中去。
        updateFn && updateFn(node,value);
    },
    setVal(vm,expr,value){
        expr = expr.split('.');
        return expr.reduce((prev,next,currentIndex)=>{
            if(currentIndex == expr.length - 1){
                return prev[next] = value;
            }
            return prev[next];
        },vm.$data);
    },
    //编译v-model指令的
    //expr的值可能是message.a.b.c
    model(node,vm,expr){
        //输入框的处理
        let updateFn = this.update['updateModel']; 
        //这里应该加一个监控,当你在处理指令的时候,添加一个watcher
        new Watcher(vm,expr,(newValue)=>{
            //当值变化后会调用cb将新值传递过来
            updateFn && updateFn(node,this.getVal(vm,expr));
        });
        node.addEventListener('input',(e)=>{
            let newValue = e.target.value;
            this.setVal(vm,expr,newValue);
        })
        updateFn && updateFn(node,this.getVal(vm,expr));
    },
    //更新的方法
    update:{
        //文档更新
        updateText(node,value){
            node.textContent = value;
        },
        //输入框更新
        updateModel(node,value){
            node.value = value;
        }
    }
}

数据劫持

数据劫持实际就是当你修改vm.$data中的数据的时候,我们定义我们的触发行为

class Observer{
    constructor(data){
        this.observe(data);
    }
    observe(data){
        //要对这个data数据改成set和get的形式
        if(!data || typeof data !== 'object'){
            return;
        }
        //要将数据一一劫持
        //1. 先获取到data的key,value
        Object.keys(data).forEach(key=>{
            //数据劫持
            this.defineReactive(data,key,data[key]);
            this.observe(data[key]);//如果值是一个对象的话,深度劫持
        })
    }
    //定义响应式
    defineReactive(obj,key,value){
        let that = this;
        let dep = new Dep();//每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true, 
            get(){
                //来到这里了就意味着target里面有值
                //什么情况下target里面有值呢?
                //observe中的get()被调用了
                //你可以理解为每次你new Watcher的时候都会将它放到dep中去
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set(newValue){
                //当我尝试修改data里面值的时候
                if(newValue != value){
                    that.observe(newValue);//如果是新对象的话,继续劫持
                    value = newValue;
                    dep.notify();
                }
            }
        })
    }
}
class Dep {
    constructor(){
        this.subs = [];
    }
    addSub(watcher){
        this.subs.push(watcher);
    }
    notify(){
        this.subs.forEach(watcher=>watcher.update());
    }
}

发布订阅

当数据劫持成功后,每当你修改vm.$data上的值的时候,我们就触发模板的重新编译,更新试图

//给需要变化的元素添加一个观察者,当数据变化后,执行对应的方法
class Watcher {
    constructor(vm,expr,cb){
        //初始化
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        //先获取一下老的值
        this.value = this.get();

    }
    getVal(vm,expr){ 
        expr = expr.split('.');
        return expr.reduce((prev,next)=>{
            return prev[next]; 
        },vm.$data);
    }
    get(){
        //将所有实例化的watcher都挂载到dep.target上
        //感觉这个方法有点Low
        Dep.target = this;
        //下面的这个方法会调用observe中的get方法
        let value = this.getVal(this.vm,this.expr);
        Dep.target = null;
        return value;
    }
    update(){
        let newValue = this.getVal(this.vm,this.expr);
        let oldValue = this.value;
        if(newValue != oldValue){
            this.cb(newValue);
        }
    }
}

2. 实现虚拟DOM

实现虚拟DOM到真实DOM的转变是为了减少操作DOM的次数,提高性能

首先使用createElement实现虚拟DOM到真实DOM的转变

class Element{
    constructor(type,props,children){
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
function setAttr(node,key,value){
    switch(key){
        case 'value':
            if(node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === 'TEXTAREA'){
                node.value = value;
            }else{
                node.setAttribute(key,value);
            }
            break;
        case 'style':
            node.style.cssText = value;
            break;    
        default:
            node.setAttribute(key,value);
            break;    
    }
}
//type类型,Prop属性,children子成员
function createElement(type,props,children){
    return new Element(type,props,children);
}
//render方法可以将虚拟dom转化为真实的dom
function render(eleObj){
    let el = document.createElement(eleObj.type);
    for(let key in eleObj.props){
        //循环将属性添加到dom上去
        setAttr(el,key,eleObj.props[key]);
    }
    //拿到所有的子孙元素children
    eleObj.children.forEach(child=>{
        child = (child instanceof Element)?render(child):document.createTextNode(child);
        el.appendChild(child);
    })
    return el;
}
function renderDom(el,target){
    target.appendChild(el);
}
export {createElement,render,Element,renderDom}

将两个不同的虚拟DOM进行比较就是dom diff

将比较后的结果记录在patches中

function diff(oldTree,newTree){
    let patches = {};
    let index = 0;
    //递归树,比较后的结果放到补丁包中
    walk(oldTree,newTree,index,patches);
    return patches;
}
function diffAttr(oldAttrs,newAttrs){
    let patch = {};
    //直接判断老的属性是否跟新的属性一样,如果不一样的话,则放到patch中
    for(let key in oldAttrs){
        if(oldAttrs[key] !== newAttrs[key]){
            patch[key] = newAttrs[key]; //有可能是undefined
        }
    }
    //判断是否有新增的属性
    for(let key in newAttrs){
        //老节点没有新节点的属性
        if(!oldAttrs.hasOwnProperty(key)){
            patch[key] = newAttrs[key];
        }
    }
    //将属性不同的对象返回
    return patch;
}
const ATTRS = 'ATTRS';
const TEXT = 'TEXT';
const REMOVE = 'REMOVE';
const REPLACE = 'REPLACE';
let Index = 0;
function diffChildren(oldChildren,newChildren,patches){
    //比较老节点的第一个和新的第一个
    oldChildren.forEach((child,idx)=>{
        walk(child,newChildren[idx],++Index,patches);
    });
}  
function isString(node){
    return Object.prototype.toString.call(node) === '[object String]';
}
function walk(oldNode,newNode,index,patches){
    let currentPatch = [];
    if(!newNode){
        currentPatch.push({type:REMOVE,index:index});
    }else if(isString(oldNode) && isString(newNode)){
        if(oldNode !== newNode){
            //判断文本是否一致
            currentPatch.push({type:TEXT,text:newNode});
        }
    }else if(oldNode.type === newNode.type){
        //先比父元素,看看类型相同不相同
        //比父元素属性,返回一个变化的对象
        let attrs = diffAttr(oldNode.props,newNode.props);
        if(Object.keys(attrs).length > 0){
            currentPatch.push({type:ATTRS,attrs});
        }
        //如果有儿子节点的话,那么遍历儿子节点接着比较
        diffChildren(oldNode.children,newNode.children,patches);
    }else{
        //替换的情况
        currentPatch.push({type:REPLACE,newNode:newNode});
    }
    //当前的父元素确实有补丁的话,放入第一层的补丁包中。
    if(currentPatch.length > 0){
        patches[index] = currentPatch;
    }
}
export default diff

//当节点类型相同的时候,看一下属性是否相同,产生一个属性的补丁包
// {type:'ATTRS',attrs:{class:'list-group'}} 属性发生变化的补丁包
// {type:'REMOVE',index:xxx} 新的dom节点被删除了
//{type:'REPLACE',newNode:newNode} 节点类型不相同,直接回替换
//{type:'TEXT',text:1}  文本的内容发生了变化

使用patch方法将变化以最小的代价更新视图

import { render,Element } from "./elemnt";

let allPathes;
let index = 0;
function setAttr(node,key,value){
    switch(key){
        case 'value':
            if(node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === 'TEXTAREA'){
                node.value = value;
            }else{
                node.setAttribute(key,value);
            }
            break;
        case 'style':
            node.style.cssText = value;
            break;    
        default:
            node.setAttribute(key,value);
            break;    
    }
}
function patch(node,patches){
    //打补丁的过程
    allPathes = patches;
    walk(node);
}
function walk(node){
    let currrentPatch = allPathes[index++];
    let childNodes = node.childNodes;
    childNodes.forEach(child=>walk(child));
    if(currrentPatch){
        doPatch(node,currrentPatch);
    }
}
function doPatch(node,patches){
    patches.forEach(patch=>{
        switch(patch.type){
            case 'ATTRS':
                for(let key in patch.attrs){
                    let value = patch.attrs[key];
                    if(value){
                        setAttr(node,key,value);
                    }else{
                        node.removeAttribute(key);
                    }
                }
                break;
            case 'TEXT':
                node.textContent = patch.text;
                break;
            case 'REPLACE':
                let newNode = (patch.newNode instanceof Element) ?
                render(patch.newNode):document.creaateTextNode(patch.newNode);
                node.parentNode.replaceChild(newNode,node);
                break;
            case 'REMOVE':
                node.parentNode.removeChild(node);
                break;
            default:
                break;        
        }
    })
}
export default patch

3. 实现vue-router

先看一个router的使用方式

new Router({
id: 'router-view', // 容器视图
mode: 'hash', // 模式
routes: [
    {
    path: '/',
    name: 'home',
    component: '<div>Home</div>',
    beforeEnter: (next) => {
        console.log('before enter home')
        next()
    },
    afterEnter: (next) => {
        console.log('enter home')
        next()
    },
    beforeLeave: (next) => {
        console.log('start leave home')
        next()
    }
    },
    {
    path: '/bar',
    name: 'bar',
    component: '<div>Bar</div>',
    beforeEnter: (next) => {
        console.log('before enter bar')
        next()
    },
    afterEnter: (next) => {
        console.log('enter bar')
        next()
    },
    beforeLeave: (next) => {
        console.log('start leave bar')
        next()
    }
    },
    {
    path: '/foo',
    name: 'foo',
    component: '<div>Foo</div>'
    }
]
})

数据驱动

首先是数据驱动,所以我们可以通过一个route对象来表述当前路由状态,比如:

current = {
    path: '/', // 路径
    query: {}, // query
    params: {}, // params
    name: '', // 路由名
    fullPath: '/', // 完整路径
    route: {} // 记录当前路由属性
}

current.route内存放当前路由的配置信息,所以我们只需要监听current.route的变化来动态render页面便可。

接着需要监听不同的路由变化,做相应的处理。以及实现hash和history模式。

这里我们延用vue数据驱动模型,实现一个简单的数据劫持,并更新视图。首先定义我们的observer

class Observer {
constructor (value) {
    this.walk(value)
}

walk (obj) {
    Object.keys(obj).forEach((key) => {
    // 如果是对象,则递归调用walk,保证每个属性都可以被defineReactive
    if (typeof obj[key] === 'object') {
        this.walk(obj[key])
    }
    defineReactive(obj, key, obj[key])
    })
}
}

function defineReactive(obj, key, value) {
let dep = new Dep()
Object.defineProperty(obj, key, {
    get: () => {
    if (Dep.target) {
        // 依赖收集
        dep.add()
    }
    return value
    },
    set: (newValue) => {
    value = newValue
    // 通知更新,对应的更新视图
    dep.notify()
    }
})
}

export function observer(value) {
return new Observer(value)
}

再接着,我们需要定义Dep和Watcher:

export class Dep {
constructor () {
    this.deppend = []
}
add () {
    // 收集watcher
    this.deppend.push(Dep.target)
}
notify () {
    this.deppend.forEach((target) => {
    // 调用watcher的更新函数
    target.update()
    })
}
}

Dep.target = null

export function setTarget (target) {
Dep.target = target
}

export function cleanTarget() {
Dep.target = null
}

// Watcher
export class Watcher {
constructor (vm, expression, callback) {
    this.vm = vm
    this.callbacks = []
    this.expression = expression
    this.callbacks.push(callback)
    this.value = this.getVal()

}
getVal () {
    setTarget(this)
    // 触发 get 方法,完成对 watcher 的收集
    let val = this.vm
    this.expression.split('.').forEach((key) => {
    val = val[key]
    })
    cleanTarget()
    return val
}

// 更新动作
update () {
    this.callbacks.forEach((cb) => {
    cb()
    })
}
}

到这里我们实现了一个简单的订阅-发布器,所以我们需要对current.route做数据劫持。一旦current.route更新,我们可以及时的更新当前页面:

// 响应式数据劫持
observer(this.current)

// 对 current.route 对象进行依赖收集,变化时通过 render 来更新
new Watcher(this.current, 'route', this.render.bind(this))

恩....到这里,我们似乎已经完成了一个简单的响应式数据更新。其实render也就是动态的为页面指定区域渲染对应内容,这里只做一个简化版的render:

render() {
    let i
    if ((i = this.history.current) && (i = i.route) && (i = i.component)) {
    document.getElementById(this.container).innerHTML = i
    }
}

hash 和 history

接下来是hash和history模式的实现,这里我们可以沿用vue-router的**,建立不同的处理模型便可。来看一下我实现的核心代码:

this.history = this.mode === 'history' ? new HTML5History(this) : new HashHistory(this)

当页面变化时,我们只需要监听hashchange和popstate事件,做路由转换transitionTo:

/**
* 路由转换
* @param target 目标路径
* @param cb 成功后的回调
*/
transitionTo(target, cb) {
    // 通过对比传入的 routes 获取匹配到的 targetRoute 对象
    const targetRoute = match(target, this.router.routes)
    this.confirmTransition(targetRoute, () => {
    // 这里会触发视图更新
    this.current.route = targetRoute
    this.current.name = targetRoute.name
    this.current.path = targetRoute.path
    this.current.query = targetRoute.query || getQuery()
    this.current.fullPath = getFullPath(this.current)
    cb && cb()
    })
}

/**
* 确认跳转
* @param route
* @param cb
*/
confirmTransition (route, cb) {
    // 钩子函数执行队列
    let queue = [].concat(
    this.router.beforeEach,
    this.current.route.beforeLeave,
    route.beforeEnter,
    route.afterEnter
    )
    
    // 通过 step 调度执行
    let i = -1
    const step = () => {
        i ++
        if (i > queue.length) {
            cb()
        } else if (queue[i]) {
            queue[i](step)
        } else {
            step()
        }
    }
        step(i)
}

这样我们一方面通过this.current.route = targetRoute达到了对之前劫持数据的更新,来达到视图更新。另一方面我们又通过任务队列的调度,实现了基本的钩子函数beforeEach、beforeLeave、beforeEnter、afterEnter。

到这里其实也就差不多了,接下来我们顺带着实现几个API吧:

/**
* 跳转,添加历史记录
* @param location 
* @example this.push({name: 'home'})
* @example this.push('/')
*/
push (location) {
    const targetRoute = match(location, this.router.routes)

    this.transitionTo(targetRoute, () => {
    changeUrl(this.router.base, this.current.fullPath)
    })
}

/**
* 跳转,添加历史记录
* @param location
* @example this.replaceState({name: 'home'})
* @example this.replaceState('/')
*/
replaceState(location) {
    const targetRoute = match(location, this.router.routes)

    this.transitionTo(targetRoute, () => {
    changeUrl(this.router.base, this.current.fullPath, true)
    })
}

go (n) {
    window.history.go(n)
}

function changeUrl(path, replace) {
    const href = window.location.href
    const i = href.indexOf('#')
    const base = i >= 0 ? href.slice(0, i) : href
    if (replace) {
    window.history.replaceState({}, '', `${base}#/${path}`)
    } else {
    window.history.pushState({}, '', `${base}#/${path}`)
    }
}

总结hash和history模式

  1. hash

随着 ajax 的流行,异步数据请求交互运行在不刷新浏览器的情况下进行。而异步交互体验的更高级版本就是 SPA —— 单页应用。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的,为了实现单页应用,所以就有了前端路由。

类似于服务端路由,前端路由实现起来其实也很简单,就是匹配不同的 url 路径,进行解析,然后动态的渲染出区域 html 内容。但是这样存在一个问题,就是 url 每次变化的时候,都会造成页面的刷新。那解决问题的思路便是在改变 url 的情况下,保证页面的不刷新。在 2014 年之前,大家是通过 hash 来实现路由,url hash 就是类似于:

http://www.xxx.com/#/login

这种 #。后面 hash 值的变化,并不会导致浏览器向服务器发出请求,浏览器不发出请求,也就不会刷新页面。另外每次 hash 值的变化,还会触发hashchange 这个事件,通过这个事件我们就可以知道 hash 值发生了哪些变化。然后我们便可以监听hashchange来实现更新页面部分内容的操作:

function matchAndUpdate () {
// todo 匹配 hash 做 dom 更新操作
}

window.addEventListener('hashchange', matchAndUpdate)
  1. history

4年后,因为HTML5标准发布。多了两个 API,pushState 和 replaceState,通过这两个 API 可以改变 url 地址且不会发送请求。同时还有 popstate 事件.调用history.pushState()或者history.replaceState()不会触发popstate事件. popstate事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在JavaScript中调用history.back()、history.forward()、history.go()方法).。

通过这些就能用另一种方式来实现前端路由了,但原理都是跟 hash 实现相同的。用了 HTML5 的实现,单页路由的 url 就不会多出一个#,变得更加美观。但因为没有 # 号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求。为了避免出现这种情况,所以这个实现需要服务器的支持,需要把所有路由都重定向到根页面。

function matchAndUpdate () {
// todo 匹配路径 做 dom 更新操作
}

window.addEventListener('popstate', matchAndUpdate)