【前端面试基础】(六)Vue框架篇

216 阅读11分钟

大家好,我是忆白,本文是根据慕课网双越老师框架面试课程的Vue部分做的一些整理以及相应扩展。

1. 父子组件创建和挂载生命周期的先后

父组件先创建(created)、再子组件创建(created);子组件先挂载(mounted),父组件再挂载(mounted),因为父组件创建了,子组件才能添加进来,子组件渲染完成了,父组件才能叫做渲染完成。

2. 自定义v-model

在定义组件时,可以指定一个model属性,允许一个自定义组件在使用 v-model 时定制 prop 和 event。 props为使用组件时,v-model后面跟的变量,event为自定义事件。 默认情况下,一个组件上的 v-model 会把 value 用作 prop 且把 input 用作 event

v-model 在内部为不同的输入元素使用不同的 property 并抛出不同的事件(下面以input为例):

  • text 和 textarea 元素使用 value property 和 input 事件;
  • checkbox 和 radio 使用 checked property 和 change 事件;
  • select 字段将 value 作为 prop 并将 change 作为事件。

子组件(CustomVModel):

​
<template>
    <!-- 例如:vue 颜色选择 -->
    <input type="text"
        :value="text1"
        @input="$emit('change1', $event.target.value)"
    >
    <!--
        1. 上面的 input 使用了 :value 而不是 v-model
        2. 上面的 change1 和 model.event1 要对应起来
        3. text1 属性对应起来
    -->
</template>
​
<script>
export default {
    model: {
        prop: 'text1', // 对应 props text1
        event: 'change1'
    },
    props: {
        text1: {
            type: String,
            default() {
                return '';
            }
        }
    }
}
</script>

父组件

<p>{{name}}</p>
<CustomVModel v-model="name"/> 
  • 这个name就会传递到子组件model属性的props中,然后子组件input的value绑定这个name变量,
  • 子组件中input的值改变时,就会触发input事件,然后在input回调中,使用$emit触发model中指定的change1事件,这个事件会自动使父组件中name同步更新为input框的值。
  • 所以我们平日直接在input框使用v-model可以写成<input :value="name" @input="name = $event.target.value">,先用v-bind绑定一个数据name,然后在input框改变时触发input事件,在事件回调中把name的值赋值为当前input框的值,因此v-model实际上是一个语法糖。

3. 动态组件

使用component标签,使用is属性传入组件名称,动态决定渲染哪个组件

<component :is="componentName">

4. Vue异步加载组件

  • import函数:普通import导入是同步的,而import函数返回的是一个Promise,可以import('../xxx').then()
  • import函数异步加载,可以让异步组件打包成单独的js,用到的时候再加载
<template>
    <FormDemo v-if="showFormDemo"></FormDemo>
    <button @click="showFormDemo = true">show Form demo</button>
</template>
​
<script>
export default {
    components: {
        FormDemo: () => import('../BaseUse/FormDemo')
    }
    data() {
        return {
            showDemo: false;
        }
    }
}
</script>

5. keep-alive缓存组件

  • 缓存组件
  • 频繁切换,不需要重复渲染
  • Vue常见的性能优化
  • 用到的时候再加载,然后mounted挂载,不用的时候不会destory而是缓存起来,下次用的时候直接从缓存使用,也不用重新渲染,mounted钩子只执行一次

6. mixin

  • 多个组件有相同的逻辑,抽离出来
  • mixin 并不是完美的解决方案,会有一些问题
  • Vue3 提出的 Composition API 旨在解决这些问题

mixin的问题:

  • 变量来源不明确,不利于阅读
  • 引入多个 mixin 可能会造成命名冲突的问题(不过生命周期钩子内容会合并在一起)
  • mixin 和组件可能出现多对多的关系,复杂度较高

7. Vue响应式

7.1 Vue2响应式实现

  • observer方法中传入需要观察的对象
  • defineReactive(target, key, value)方法用来重新定义target对象的set和get,key为属性,value为key对应的值
  • 数组的响应式不能用Object.defineProperty,在这里我们新建了一个对象arrProto,这个对象的原型指向原始数组原型,在这个对象中重写了一些数组方法,在重写的方法中触发了视图更新,之后如果需要监听数组,我们就让数组的原型指向arrProto,这样就会使用我们重写后的方法。(所以当数组arr需要监听时,它的原型链是这样的:arr -> arrProto -> Array.prototype)
// 触发更新视图
function updateView() {
    console.log('视图更新')
}
​
// 重新定义数组原型
// 先保存原本的数组原型
const oldArrayProperty = Array.prototype
// 创建一个新对象arrProto,它的原型指向 oldArrayProperty 也就是原始数组原型,新对象上再扩展新的方法不会影响原始数组原型
const arrProto = Object.create(oldArrayProperty);
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(methodName => {
    // 在新建的arrProto中实现这些方法,添加视图更新并执行原始数组的该方法
    arrProto[methodName] = function () {
        updateView() // 触发视图更新
        oldArrayProperty[methodName].call(this, ...arguments)// 执行原始数组原型的方法,相当于下一行
        // Array.prototype.push.call(this, ...arguments)
    }
})
​
// 重新定义属性,监听起来
function defineReactive(target, key, value) {
    // 深度监听
    observer(value)
​
    // 核心 API
    Object.defineProperty(target, key, {
        get() {
            return value
        },
        set(newValue) {
            if (newValue !== value) {
                // 深度监听
                observer(newValue)
​
                // 设置新值
                // 注意,value 一直在闭包中,此处设置完之后,再 get 时也是会获取最新的值
                value = newValue
​
                // 触发更新视图
                updateView()
            }
        }
    })
}
​
// 监听对象属性
function observer(target) {
    if (typeof target !== 'object' || target === null) {
        // 不是对象或数组,不用监听
        return target
    }
​
    // 如果传入数组,则让数组的原型指向我们上面创建的新对象arrProto,这样执行方法的时候是执行带有视图更新的重写方法
    if (Array.isArray(target)) {
        target.__proto__ = arrProto
    }
​
    // 重新定义各个属性(for in 也可以遍历数组)
    for (let key in target) {
        defineReactive(target, key, target[key])
    }
}
​
// 准备数据
const data = {
    name: 'zhangsan',
    age: 20,
    info: {
        address: '北京' // 需要深度监听
    },
    nums: [10, 20, 30]
}
​
// 监听数据
observer(data)

7.2 Object.definedProperty 缺点

  • 深度监听,需要递归到底,一次性计算量大
  • 无法监听新增属性/删除属性(通过Vue.set / Vue.delete解决)
  • 无法原生监听数组,需要特殊处理(重写数组方法)

8. 虚拟DOM(Virtual DOM)和 diff

  • vdom是实现 vue 和 React 的重要基石
  • diff 算法是 vdom 中最核心、最关键的部分

DOM 操作非常耗费性能,解决方案 vdom

8.1用 JS 模拟 DOM 结构

<div id="div1" class="container">
    <p>vdom</p>
    <ul style="font-size: 20px">
        <li>a</li>
    </ul>
</div>
{
    tag: 'div',
    props: {
        className: 'container',
        id: 'div1'
    }
    children: [
        {
            tag: 'p',
            children: 'vdom'
        },
        {
            tag: 'ul',
            props: { style: 'font-size: 20px' }
            children: [
                {
                    tag: 'li'
                    children: 'a'
                }   
            ]
        }
    ]
}

8.2 diff算法概述

两棵树进行diff的时间复杂度是 O(n^3)

优化时间复杂度到 O(n)

  • 只比较同一层级,不跨级比较

diff(1).png

  • tag 不相同,则直接删掉重建,不再深度比较

diff(2).png

  • tag 和 key,两者都相同,则认为是相同节点,不再深度比较

patchVnode:

  • 新节点没有text文本

    • 新旧都有children,则执行updataChildren
    • 新有children,旧没有,则清空旧的text,并添加children
    • 旧有children,新没有,则移除旧的children
    • 旧有text,直接清空
  • 新节点有text,则没有children子节点

    • 如果旧节点有子节点children,直接移除,然后设置旧节点text为新节点text值

updataChildren:

  • 定义四个指针,分别指向新节点子节点children的头和尾,以及旧节点子节点children的头和尾

  • 然后新旧子节点开始和开始对比,结束和结束对比,开始和结束对比,结束和开始对比,对应上之后指针都往中间走

  • 如果都没命中,拿新节点的key,对应旧children中的key,以及相应的元素类型

    • 如果一个key都对应不上,说明这个节点是新增的,直接插入
    • 如果对应上,并且元素类型也对应上,说明是相同节点,就把旧节点直接移动到对应位置

diff(3).png

9. 模板编译

  • 前置知识:JS 的 with 语法
  • vue template complier 将模板编译成 render 函数
  • 执行 render 函数生成 vnode

9.1 with语法

  • 改变 { } 内自由遍历的查找规则,当作 obj 属性来查找
  • 如果找不到匹配的 obj 属性,就会报错
  • with 要慎用,它打破了作用域规则,易读性变差

不使用with:

const obj = {a: 100, b: 200};
​
console.log(obj.a);
console.log(obj.b);
console.log(obj.c); // undefined

使用with时,能改变{}中自由变量的查找方式,将{}内的自由变量,当作 obj 的属性来查找

with (obj) {
    console.log(a); // 相当于console.log(obj.a);
    console.log(b);
    console.log(c); // 会报错
}

9.2 编译模板

  • 模板编译为 render 函数,执行 render 函数返回 vnode
  • 基于 vnode 再执行 patch 和 diff
  • 使用 webpack vue-loader ,会在开发环境下编译模板(生产环境直接使用编译好的)

9.3 vue 组件中使用 render 代替 template

Vue.component('heading', {
    // template: 'xxxx',
    render: function(createElement) {
        return createElement(
            'h',
            [
                createElement('a', {
                    attrs: {
                        name: 'headerId',
                        href: '#' + 'headerId'
                    }
                }, 'this is a tag')
            ]
        )
    }
})

10. 组件 渲染/更新 过程(考察流程全面度)

10.1 初次渲染的过程

  • 解析模板为 render 函数(或者在开发环境打包已经完成,使用vue-lodaer)

  • 触发响应式,监听 data 属性 getter、setter

执行render函数触发getter.png

  • 执行 render 函数,生成 vnode ,然后patch(elem, vnode),把vnode挂载到elem上

10.2 更新过程

  • 修改 data ,触发 setter (此前在 getter 中已被监听)
  • 重新执行 render 函数,生成 newVnode
  • 之后执行patch(vnode, newVnode)

vue组件渲染更新流程图.png

初次渲染和更新合并过程:

  1. 将data里的属性挂载到vue实例上
  2. 利用getter和setter监听data上的属性
  3. 将模板编译为render函数,结合data里数据执行render生成vnode
  4. 执行render函数时,会触发data属性的getter,在getter函数里将这些依赖都收集起来。(watcher)
  5. 通过patch生成dom节点。
  6. 修改data时,notify watcher,看修改的data是不是在watcher里。(这是在data里data.age=20新增属性时,无法实现响应式的原因)
  7. 如果之前被收集起来的依赖有被notify,就重新执行render函数。

10.3 异步渲染

因为数据驱动视图,而修改数据是很容易并且很频繁的,如果频繁更新视图,DOM频繁改变,性能就会下降,因此异步渲染是很重要的。

  • $nextTick

  • 汇总 data 的修改,一次性更新视图

    大致思路就是:

    • 同步修改 data 时,把修改操作汇总到一个队列中
    • 在异步时取得这个队列,将其中的修改数据汇总,就像 Object.assign
    • 用汇总的结果统一修改 data ,触发试图更新
  • 减少 DOM 操作次数,提高性能

11. 前端路由原理

网页 url 组成部分:

网页url组成部分.png

11.1 hash 的特点

  • hash 变化会触发页面跳转,即浏览器的前进、后退
  • hash 变化不会刷新页面,SPA 必需的特点
  • hash 永远不会提交到 server 端(前端自生自灭)

11.2 js实现hash路由

<p>hash test</p>
<button id="btn1">修改 hash</button>
​
<script>
    // hash 变化,包括:
    // a. JS 修改 url
    // b. 手动修改 url 的 hash
    // c. 浏览器前进、后退
    window.onhashchange = (event) => { // 监听hash改变
        console.log('old url', event.oldURL)
        console.log('new url', event.newURL)
​
        console.log('hash:', location.hash)
    }
​
    // 页面初次加载,获取 hash
    document.addEventListener('DOMContentLoaded', () => {
        console.log('hash:', location.hash)
    })
​
    // JS 修改 url
    document.getElementById('btn1').addEventListener('click', () => {
        location.href = '#/user'
    })
</script>

11.3 H5 history

  • 用 url 规范的路由,但跳转时不刷新页面
  • history.pushState
  • window.onpopState监听
  • 需要服务端配合,无论访问哪个路由,都返回index.html,因为当页面带上路由之后,如果刷新,会出现找不到页面的错误。
<p>history API test</p>
<button id="btn1">修改 url</button>
​
<script>
    // 页面初次加载,获取 path
    document.addEventListener('DOMContentLoaded', () => {
        console.log('load', location.pathname)
    })
​
    // 打开一个新的路由
    // 【注意】用 pushState 方式,浏览器不会刷新页面
    document.getElementById('btn1').addEventListener('click', () => {
        const state = { name: 'page1' }
        console.log('切换路由到', 'page1')
        history.pushState(state, '', 'page1') // 重要!!
    })
​
    // 监听浏览器前进、后退
    window.onpopstate = (event) => { // 重要!!
        console.log('onpopstate', event.state, location.pathname)
    }
​
    // 需要 server 端配合,可参考
    // https://router.vuejs.org/zh/guide/essentials/history-mode.html#%E5%90%8E%E7%AB%AF%E9%85%8D%E7%BD%AE%E4%BE%8B%E5%AD%90
</script>

history接口是HTML5新增的,它有六种模式改变URL而不刷新页面:

  • replaceState: 替换原来的路径
  • pushState: 使用新的路径
  • popState:路径的回退
  • go:向前或向后改变路径
  • forward:向前改变路径
  • back:向后改变路径

11.4 两者选择

  • to B 的系统(后台系统)推荐使用 hash,简单易用,对 url 规范不敏感
  • to C 的系统,可以考虑选择 H5 history,但需要服务端支持
  • 能选简单的,就别用复杂的,要考虑成本和收益

12 面试题

v-show 和 v-if 的区别

  • v-show 通过 CSS display 控制显示和隐藏,依然会渲染出来,在DOM树中节点存在
  • v-if 组件真正的渲染和销毁,而不是显示隐藏,不满足条件不会渲染,在DOM树中没有该节点
  • 频繁切换显示状态使用v-show,否则使用v-if

为何在 v-for 中使用 key

  • 必须使用 key,且不能是 index 和 random
  • 在 diff 算法中通过标签 tag 和 key 来判断,是否是相同节点sameNode,从而优化 diff 算法时间复杂度
  • 因此可以减少渲染次数,提升渲染性能

常见的Vue组件通信方式

  • 父子组件 props 和 this.$emit
  • 事件总线,自定义事件,event.on(绑定事件)event.on(绑定事件)、event.off(解绑事件)、event.$emit(触发事件)
  • 祖先与后代组件,provide 与 inject
  • vuex

双向数据绑定 v-model 的实现原理

  • input 元素的 value = this.name(假设name就是v-model绑定的变量)
  • 绑定 input 事件,然后this.name = $event.target.value,每当input框改变触发input事件,然后将输入框中的值赋值给之前绑定的变量
  • data 更新就会触发 re-render,模板重新渲染,就实现了双向数据绑定

computed 有何特点

  • 缓存,data 数据不变,就不会重新计算
  • 提高性能

为何组件中 data 必须是一个函数?

组件被编译之后,实际上是一个class,在每个地方使用组件实际上是对这个class实例化,实例化的时候执行data,如果不是函数,则每个组件实例的数据都一样(通过原型回溯),一个组件修改data,会导致其他组件的data跟着改变,无法达到复用的目的。如果是一个函数,则data数据存在于闭包中,组件间就不会互相影响。

模拟处理:

const MyComponent = function() {};
MyComponent.prototype.data = {
    a: 1,
    b: 2
}
const component1 = new MyComponent();
const component2 = new MyComponent();
 
component1.data.a === component2.data.a; // true
component1.data.b = 5;
component2.data.b //5

如果两个实例同时引用一个对象,那么当你修改其中一个属性的时候,另外一个实例也会跟着改变;

两个实例应该有自己各自的域才对,需要通过下面的方法进行处理

const MyComponent = function() {
    this.data = this.data();
};
 
MyComponent.prototype.data = function() {
    return {
        a:1,
        b:2
    }
}

如何将一个组件所有属性props 传递给子组件

  • 使用$props
  • <User v-bind:"$props" />
  • 一个细节知识点,优先级不高

多个组件有相同的逻辑,如何抽离?

  • 使用mixin
  • 以及mixin一些缺点(前面总结过)
  • Vue3使用 Composition API

何时要使用异步组件

  • 加载大组件
  • 路由异步加载

何时需要使用 keep-alivie?

  • 缓存组件,不需要重复渲染
  • 比如多个静态 tab 页的切换
  • 可以优化性能

何时需要使用 beforeDestory

  • 解绑自定义事件 event.$off
  • 清除定时器
  • 解绑自定义的原生DOM事件(使用vue绑定的事件,vue会帮我们解除,但自己绑定的不会),如window.scroll等
  • 以上三点不做的话,容易造成内存泄漏

什么是作用域插槽

作用域插槽是为了让插槽将所在组件的data中属性,向外层传递给插槽使用者。

Vuex中action和mutation有何区别

  • action 中处理异步,mutation 不可以
  • mutation 中做原子操作,即一次只做一个操作
  • action 可以整合多个 mutation

vue-router 常用的路由模式

  • hash 默认
  • H5 history(需要服务端支持)
  • 两者比较

Vue 常见性能优化方式

  • 合理使用 v-show 和 if
  • 合理使用 computed
  • 使用 v-for 时加 key,以及避免和 v-if 同时使用(v-for优先级更高,每次v-for都会重新执行v-if计算)
  • 自定义事件,DOM事件及时销毁,否则会造成内存泄漏
  • 合理使用异步组件
  • 合理使用 keep-alive
  • data 层级不要太深,vue2深度监听需要一次性遍历完成,这样会导致响应式做监听时递归次数比较多
  • 使用 vue-loader 在开发环境做模板编译(预编译)
  • webpack 层面的优化:
  • 前端通用的性能优化,如图片懒加载
  • 使用 SSR

写在最后

如果你觉得我写的还不错,欢迎给我点个赞哦~