Vue2

推荐一个学习vue的网站:Vue3

说说你对vue的理解

前端发展背景

最早的网页是没有数据库的,可以理解成就是一张可以在网络上浏览的报纸,就是纯静态页面

直到CGI技术的出现,通过 CGI Perl 运行一小段代码,与数据库或文件系统进行交互(前后端交互)

后来JSP(Java Server Pages)技术取代了CGI技术,其实就是Java + HTML

1
2
3
4
5
6
7
8
9
10
11
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>JSP demo</title>
</head>
<body>
<img src="http://localhost:8080/web05_session/1.jpg" width="200" height="100" alt="示例图片" />
</body>
</html>

JSP有一个很大的缺点,就是不太灵活。JSP使用 Java 而不是 JavaScript,并且 Java 代码只能在服务器端运行。我们每次的请求:获取的数据、内容的加载,服务器都会做对应的处理,并渲染dom然后返回渲染好的dom,简单的来说,JSP把页面的渲染工作完全交给后端服务器。

后来ajax火了,它允许用户在不刷新整个页面的前提下,和后端服务器交换数据,并由浏览器执行js代码,更新部分页面。

随后移动设备的普及,Jquery的出现,以及SPA(Single Page Application 单页面应用)的雏形,Backbone EmberJS AngularJS 这样一批前端框架随之出现,但当时SPA的路不好走,例如SEO问题,SPA 过多的页面、复杂场景下 View 的绑定等,都没有很好的处理。

经过这几年的飞速发展,节约了开发人员大量的精力、降低了开发者和开发过程的门槛,极大提升了开发效率和迭代速度。我们可以看到Web技术的变化之大与快,每一种新的技术出现都是一些特定场景的解决方案,那我们今天的主角Vue又是为了解决什么呢?

Vue是什么

是一个用于创建用户界面的开源JavaScript框架,也是一个创建单页应用(SPA)的前端框架。

Vue核心特性

  • 数据驱动视图更新

    数据驱动(MVVM),相比于react,开发者无需手动调用 setState来提示视图更新。

    MVVM表示的是 Model-View-ViewModel

    • Model:模型层,负责处理业务逻辑以及和服务器端进行交互
    • View:视图层:负责将数据模型转化为UI展示出来,可以简单的理解为HTML页面
    • ViewModel:视图模型层,用来连接Model和View,是Model和View之间的通信桥梁,在vue中这个桥梁是vue实例
  • 组件化

    降低了代码的耦合度,可维护性,可扩展性高,便于调试。vue中的组件可分为单文件组件和多文件组件,vue中的组件是能实现部分功能的css,js,html等代码和资源的集合。

  • 指令系统

    指令 (Directives) 是带有 v- 前缀特殊属性,当表达式的值改变时,将其产生的连带影响,响应式地作用于 DOM。简单的来说,vue中的指令系统简化了dom操作,而react中没有指令系统。

Vue的学习路线

原生开发

通过script标签引入vue.js,src属性通常是http链接,或者下载到本地的vue.js文件的路径。

1
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>

如果是http链接,当浏览器加载这个脚本,会发送一个get请求获取并执行vue的js代码,类似jsonp请求。

引入vue.js后,Vue这个构造函数成为全局变量,挂载到window对象上

然后我们在页面的script标签中写些代码,创建一个vue实例,传入一个配置对象

1
2
3
4
5
6
7
8
9
10
11
const app = new Vue({
el: '#app',
data: {
},
computed: {
},
methods: {
},
watch: {
}
})

此时我们还未引入组件的概念,但是我们已经能够学习vue的大部分知识点了。包括模板语法,数据绑定,数据代理如何实现,vue的常用指令,计算属性,数据监听,vue的生命周期等等。

原生开发之组件化开发

什么组件?组件化开发有什么好处?

在vue中,组件就是能实现局部功能html,css,js代码的集合,组件化开发有利于代码复用,提高开发效率,同时把功能上密切相关的html,css,js代码放到一起,依赖关系明确,易于维护。

vue的组件可分为单文件组件非单文件组件,非单文件组件就是通过Vue.extend({}),返回一个VueComponent构造函数,这个

构造函数被用来创建组件实例,依赖的配置对象就是Vue.extend({})传入的对象,这个配置对象的结构和new Vue()传入的配置对象的结构几乎一致。存在如下关系,即Vuecomponent是Vue的子类

1
2
组件实例._proto_ = VueComponent.prototype
VueComponent.prototype._proto_ = Vue.prototype

非单文件组件使用

1
2
3
4
5
6
<div id="app" :name="str">
<div>{{str}}</div>
<div class="box">
<School></School>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//定义一个school组件
const School = Vue.extend({
template:`<div>{{name}}</div>`,
data(){
return {
name:'tom'
}
}
})
const app = new Vue({
el: '#app',
data: {
str: "haha",
keyword:""
}
//组件注册
components:{School}
})

单文件组件

单文件组件就是我们熟知的.vue文件, 单文件组件解决了非单文件组件无法复用css代码的问题,我们开发过程中使用的最多的组件也是单文件组件。

显然,.vue文件是vue团队开发的文件,无法在浏览器上运行,所以我们需要借助打包工具webpack来处理这个文件,webpack又是基于nodejs的,nodejs是使用模块化开发的。这样vue的开发就过渡到了基于nodejs+webpack的模块化开发,为了简化模块化开发过程中webpack的配置,vue团队就开发了vue-cli,即vue的脚手架

单文件组件的大致结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div></div>
</template>

<script>
export default {

}
</script>
<style>

</style>

其中export default {}export default Vue.extend({})简化而来的,组件注册的时候会自动处理:如果发现注册的组件是一个对象,则使用Vue.extend包裹,否则直接注册。

组件之间通过嵌套确定层级关系,所有其他组件都在根组件App.vue内,根组件直接嵌入index.html文件;

组件化开发后不需要直接在html页面中写结构,内容被分解为一个一个vue组件。

谈谈对el和template属性的理解

当我们在学习Vue的基础语法,vue的组件的时候一定涉及到了这两个容易混淆的属性。

  • 创建Vue根实例必须要给出el属性,指明要为哪个容器服务,这个容器会成为模板;创建组件实例不能传入el属性,简单的来说,el属性是Vue根实例独有的

  • 如果创建vue根实例同时配置了el和template属性,则template将替换el指定的容器成为模板(可以参考vm.$mount源码,template属性优先级更高),不过要注意的是nodejs开发环境中,通过import导入的vue是精简版的,没有模板解析器的, 模板解析器被单独提取出来,作为一个开发环境的包(生产环境打包就不会将模板解析器包含进去,从而减小最终文件的体积),用来处理.vue文件中的template,所以在创建vue根实例的时候不能使用template,所以无法借助它实现在页面中自动插入Vue.app的效果。

    1
    2
    3
    4
    5
    6
    7
    import App from './App.vue'
    import Vue from 'vue'
    new Vue({
    el:'#root',
    template:'<App></App>',
    components:{App}
    })

    上述代码会报错,不能配置template

    应当修改为:

    1
    2
    3
    4
    5
    6
    import App from './App.vue'
    import Vue from 'vue'
    new Vue({
    el:'#root',
    render:h => h(App)//传入的h是createElement函数,用来创建VNode
    })

    或者引入完整版的vue.js

    1
    import Vue from 'vue/dist/vue.js'
  • 创建组件必须指定组件的结构,即template,组件的模板,不必指定组件为哪个容器服务(不要el)

  • el指定的容器中的结构可以被抽离为一个一个单独的模板template,一个个单独的组件,也就是说模板中可以不写实际结构,只写组件标签,这些组件标签会在模板解析的时候被解析。

  • 其实组件中的template也能被拆分,从而形成一个一个组件,这就是组件的嵌套。

说说Vue的生命周期

vue的生命周期指的是vue实例从创建到销毁的过程,可分为vue实例初始化前后,dom挂载前后,数据更新前后,vue实例销毁前后四个阶段。这四个阶段分别对应了8个生命周期函数。

生命周期函数指的是在vue实例特定时间段执行的函数。

这里拿vue2的生命周期函数举例。

  • beforeCreate:vue实例刚被创建,能拿到this,部分初始化工作完成,但是数据代理还未开始(未调用initState方法),此时无法通过this获取data和methods等
  • created: 此时几乎所有配置属性比如inject,data,method,computed,props,watch,provide都初始化完成,但是模板解析(是为了得到render函数,render函数是用来创建虚拟dom的)还未开始(未调用vm.$mount方法),页面展示的是未经vue编译的dom
  • beforeMount:template模板解析结束,render函数创建完毕,但是虚拟dom还未转化成真实dom挂载到页面中,此时展示的还是旧的页面(未经编译的页面)
  • mounted:把初始的真实DOM放入页面,此时对dom的操作是有效的。
  • beforeUpdate:此时数据是新的,页面展示的内容是旧的,因为vue视图是异步更新的。
  • updated: 此时新旧虚拟dom比较完毕,页面已更新。
  • beforeDestroy:当执行beforeDestroy的钩子的时候,Vue实例就已经从运行阶段进入销毁阶段,但身上所有的data和methods,以及过滤器、指令等,都处于可用状态,还未真正执行销毁的过程
  • destroyed: 完全销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器;并不能清除DOM,仅仅销毁实例。所以页面并不会改变,但是变得无法响应交互。

数据请求在created和mouted的区别

  • 这两个阶段数据方法都已经初始化,都能通过this访问到,因为created的执行时期更早,所以能更早的发送请求,更快的返回数据。
  • 一个组件中有子组件,它们的生命周期函数的执行顺序是先执行父组件的前三个声明周期函数,再执行子组件的前四个生命周期函数,然后在执行哦父组件的mouted函数。

对于vue3中的生命周期的介绍,参考《vue》一文。

说说你对vue双向绑定的理解

双向绑定不等同于响应式了,这两个东西是有区别的。

响应式

当更改响应式数据时,视图会随即自动更新,那在vue中是如何实现数据的响应式的呢?其实主要就分为2部分,给数据添加响应式和依赖收集

  1. new Vue()首先执行初始化,对data执行响应化处理,这个过程发生constructor->_init->initState->initData->observe
  2. 同时对模板执行编译,找到其中动态绑定的数据,从data中获取并初始化视图,这个过程发生在Compile
  3. 同时定义⼀个更新函数和Watcher,将来对应数据变化时,Watcher会调用更新函数
  4. 由于data的某个key在⼀个视图中可能出现多次,所以每个key都需要⼀个管家dep来管理多个Watcher
  5. 将来data中数据⼀旦发生变化,会首先找到对应的dep,通知所有Watcher执行更新函数

具体代码参考《说说Vue实例挂载过程中发生了什么》其中有详细的介绍。

双向绑定

双向绑定,是数据变化驱动视图更新,视图更新触发数据变化。其实就是v-model的功能,而我们知道v-model只是一个语法糖。因此如果要问双向绑定的原理,思路应该是如何实现这个语法糖。其原理是把input的value绑定data的一个值,当原生input的事件触发时,用事件的值来更新data的值。

1
2
3
4
5
<!-- 使用 v-model -->
<input v-model="message" />

<!-- 编译后的等效代码 -->
<input :value="message" @input="e => {message = e.target.value}" />

说说Vue实例挂载过程中发生了什么

我们都听过知其然知其所以然这句话

那么不知道大家是否思考过new Vue()这个过程中究竟做了些什么?

过程中是如何完成数据的绑定,又是如何将数据渲染到视图的等等。下面给出简要流程:

  • 在构造函数中调用_init方法

  • _init方法内部:

    • 做一些初始化工作
    • 调用beforeCreate钩子
    • 初始化Injections
    • 初始化state
    • 初始化Provide
    • 调用created钩子
    • 调用vm.$mount方法
  • initState方法内部,依次调用:

    • initProps
    • initMethods
    • initData
    • initComputed
    • initWatch
  • initData方法内部

    • 检查data中的属性,是否和props和method中的属性有冲突
    • 调用proxy方法,把数据代理到this上,简化访问路径
    • 调用observe方法,给数据添加响应式
  • initProps方法内部

    • 创建一个空对象,赋值给vm._props

    • 校验props中的key是否合法,得到一个value

    • 把这个value和对应的key,响应式地添加到vm._props,最终代理到vm。

vue构造函数源码

首先找到vue的构造函数

1
2
3
4
5
6
7
8
9
//源码位置:src\core\instance\index.js
function Vue (options) {
if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue) ) {
//如果不说生产环境且不是Vue实例调用这个构造函数就报错
warn('Vue is a constructor and should be called with the `new` keyword')
}
//this指向创建的Vue实例
this._init(options)
}

options是用户传递入的配置对象,包含data、methods等常用属性。

vue构建函数调用了_init方法,并传入了options,所以我们关注的核心就是_init方法:

_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//位置:src\core\instance\init.js
Vue.prototype._init = function (options?: Object) {
//vm = this = vue实例
const vm: Component = this
....
// 初始化组件生命周期标志位
initLifecycle(vm)
// 初始化组件事件侦听
initEvents(vm)
// 初始化渲染方法
initRender(vm)

// 调用生命周期钩子'beforeCreate'
callHook(vm, 'beforeCreate')

// 初始化注入内容,在初始化data、props之前。
initInjections(vm) // resolve injections before data/props
// 初始化 props/data/method/watch/methods/computed
initState(vm)
//之所以最后初始化Provide,因为Provide引用的数据就是data或者computed等属性中的。
initProvide(vm)

// 调用生命周期钩子'created',此时不光是data,props,method,watch,provide等几乎所有配置属性都完成了初始化的工作
callHook(vm, 'created')
....
// 挂载元素
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}

分析后得出如下结论:

  • 在调用beforeCreate之前,主要做一些数据初始化的工作,数据初始化并未完成,像dataprops这些对象内部属性无法通过this访问到。所以说beforeCreate的执行时机先于data()函数调用,data函数调用,是在初始化data的时候被触发的。
  • 执行created的时候,数据已经初始化完成,能够通过this访问dataprops这些对象的属性,但这时候并未完成dom的挂载,因此无法访问到dom元素
  • 通过调用vm.$mount方法实现了dom挂载

我们先主要分析initState方法

initState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//源码位置:src\core\instance\state.js
//vm是vue实例
export function initState (vm: Component) {
// 初始化组件的watcher列表
vm._watchers = []
const opts = vm.$options
// 初始化props
if (opts.props) initProps(vm, opts.props)
// 初始化methods,要做的其实很简单,单纯把methods中的全部方法挂载到this上就行
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
// 初始化data
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
// 初始化computed
if (opts.computed) initComputed(vm, opts.computed)
// 初始化watch
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}

分析后发现,initState方法依次,统一初始化了props/methods/data/computed/watch,说明在created的时候,这些东西都准备好了,或者说初始化工作都完成了。

我们继续分析initState中的initData方法,关于initPropsinitComputed等其他属性的初始化做了什么,这里暂时不深入研究。

initData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function initData (vm: Component) {
let data = vm.$options.data
// 判断data的类型是不是函数,如果是则调用函数,并把返回值赋予局部变量data,同时赋值给vm._data
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
.....
//获取data中所有可枚举属性
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
const key = keys[i]
if (process.env.NODE_ENV !== 'production') {
// 属性名不能与methods中的属性名重复
if (methods && hasOwn(methods, key)) {
warn(`Method "${key}" has already been defined as a data property.`,vm)
}
}
// 属性名不能与props名称重复
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' && warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) { // 验证key值的合法性
// 将vm._data中的key属性,代理到vm上, 这样就可以通过this.key访问到vm._data.key的值(this=vm)
// 所以说vm._data指向的对象是代理源对象
proxy(vm, `_data`, key)
}
}
// observe data
// 监听data中数据的变化,data中的数据改变会触发视图更新
// 由第一行代码可知data指的是一个局部变量,它和vm._data指向同一个数据对象,由于添加了数据代理
// 修改this.key的值也会触发视图更新
observe(data, true /* asRootData */)
}

阅读源码后发现:

  • propsmethoddata之前就被初始化了,所以data中的属性值不能与propsmethods中的属性值重复;之所以要防止重复,因为它们都会被代理到this(vm)上(是的,包括props中的数据),都是直接通过this来访问,重复了就会产生冲突。同时我们也可以发现,props中的数据的优先级是高于data中的数据的,因为初始化的时机更早
  • data定义的时候可选择函数形式或者对象形式(组件只能为函数形式),data()函数调用是为了产出数据,挂载到vm._data上,然后再给数据添加代理添加响应式,所以data函数被调用的时候,内部是无法通过this来访问其他数据的。
  • initData方法把vm._data中的属性代理到vm上并给vm._data上的数据添加了响应式(实现了数据的代理,给数据添了响应式)。

vue的数据代理核心在于proxy方法,我们来看看它做了什么

proxy方法

1
2
3
4
5
6
7
8
9
10
11
12
function proxy(target, sourceKey, key) {
Object.defineProperty(target, key, {
get() {
return target[sourceKey][key];
},
set(newValue) {
target[sourceKey][key] = newValue;
},
configurable: true // 允许后续删除或重新定义该属性
});
}
proxy(vm, `_data`, key)

再次之后,访问target.key返回的就是target.sourceKey.key,说到底还是从target上面取数据,只不过简化了访问的路径。

vue给数据添加响应式的核心在于observe方法,我们来分析一下这个方法

observe方法源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function observe(obj) {  
//如果传入的不是对象或者传入的是null(typeof null返回的也是object)就直接返回
if (typeof obj !== "object" || obj == null) {
return;
}
new Observer(obj); //返回一个Observer实例,这个实例的value属性就是添加了响应式的数据
}
class Observer {
constructor(obj) {
this.value = obj;
this.walk(value);
}
walk(obj) {
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
}

我们很容易发现这个类的核心是defineReactive方法,那么这个方法内部到底做了些什么呢?其实它主要就做了2个工作:劫持属性和收集属性的依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const obj = { name: 'tom', age: 22 }
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
function defineReactive(obj, key, value) {
//如果是对象,则递归添加响应式
if(typeof value === 'object' ){
this.observe(value)
}
//为每个key创建一个Dep实例
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
//触发getter收集依赖
if(Dep.target){
dep.addDep(Dep.target)
}

//这里不能写成obj[key],否则会陷入无限递归,即在get中触发了get
//返回的是闭包中的value值,也就是说再添加getter之前,先把数据取出来
return value
},
set(val) {
// 修改的是闭包中的value值,并没有直接修改obj中的数据。
// 修改obj[key]也会陷入无限递归,因为再set中触发了set
if(value === val){
return
}
value = val
//通知依赖更新
dep.notify();
}
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Dep {  
constructor() {
// this指向某个key的dep实例
// 这个数组中存储的是一个一个的Watcher实例
this.deps = []; // 依赖管理
}
addDep(dep) {
//添加依赖,其实dep就是一个Watcher实例
this.deps.push(dep);
}
notify() {
//通知所有Watcher
this.deps.forEach((dep) => dep.update());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 负责更新视图  
class Watcher {
constructor(vm, key, updater) {
this.vm = vm
this.key = key
this.updaterFn = updater
// 创建实例时,把当前实例指定到Dep.target静态属性上
Dep.target = this
// 读一下key,触发get,收集依赖
vm[key]
// 置空,因为已经收集到依赖了
// 这就意味着在创建Watcher实例的时候,已经收集到依赖了
Dep.target = null
}
// 未来执行dom更新函数,由dep调用的
update() {
//这行代码的作用是将当前Watcher实例,push到异步更新队列
this.updaterFn.call(this.vm, this.vm[this.key])
}
run(){//进行具体的更新工作}
}

可以看到:

  • defineReactive方法的核心在于使用Object.defineProperty给对象属性添加监听且没有借助其他源对象。而是闭包中的数据。

  • 如果val的值是对象,则递归添加响应式

  • 触发get要收集依赖,为每个key都创建一个Dep实例,来管理这个key的所有依赖(Watcher)

  • 触发set的时候,调用dep.notify(),通知这个key的所有依赖更新

  • 每个Watcher实例的三大属性:vm,key,updateFn,创建Watcher实例是在模板编译的时候进行的。

  • 修改后的Obj对象结构如图,可以看到原来的属性被覆盖了,变得不可枚举。

补充:Watcher类别

  • 渲染 Watcher(Render Watcher)

    • 作用:负责组件的视图更新,每个组件对应一个渲染 Watcher。

    • run方法行为:执行getter函数(即updateComponent),触发 _render()生成新虚拟DOM,调用_update()对比新旧虚拟 DOM(Diff 算法),最终更新真实 DOM

    • 结论:渲染 Watcher 的 run 方法一定会触发 render 方法。

  • 计算属性 Watcher(Computed Watcher)

    • 作用:追踪计算属性的依赖,缓存计算结果

    • run方法行为:重新计算计算属性的值,但不会触发 render 方法,除非计算结果变化且被模板引用

  • 用户自定义 Watcher(通过 watch 选项)

    • 作用:监听数据变化并执行回调函数

    • run方法行为:执行用户定义的回调函数(如 handler),不会触发 render 方法

彻底理解vue里面的各种watcher及其作用 - 简书

总结

分析之后我们发现,vue2中的数据代理数据监听都是通过Object.defineProperty实现的。

initProps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//传入的第二个参数是子组件中的props属性的值(props配置对象)
function initProps(vm, propsOptions) {
const propsData = vm.$options.propsData || {};
//创建一个空对象
const props = vm._props = {};
// 遍历 Props 配置
for (const key in propsOptions) {
//使用propsOptions校验propsData
const value = validateProp(key, propsOptions, propsData, vm);
// 给创建的props(空对象)添加响应式
defineReactive(props, key, value);
// 代理到实例(this),然后就能直接通过this访问
if (!(key in vm)) {
proxy(vm, '_props', key);
}
}
}

当父数据是响应式时,子组件通过依赖收集成为订阅者,父数据变化自动触发子组件更新。

vue的构造函数中使用的挂载方法是vm.$mount,我们尝试分析它的源码:

vm.$mount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
Vue.prototype.$mount = function (el?: string | Element,hydrating?: boolean): Component {
// 如果目标元素存在,捕获它,得到它的dom
el = el && query(el)
// vue不允许直接挂载到body或着html上
if (el === document.body || el === document.documentElement) {
//抛出异常
......
}
//提取出options,后续使用不需要通过this
const options = this.$options
//如果没有render属性,也就是说没有render函数,解析 template/el 并转换为 render 函数
if (!options.render) {
//如果配置对象中没有render,则提取出template
let template = options.template
// 存在template模板,解析vue模板文件
// 第一个if主要是为了处理template属性的另类值,比如id选择器,dom对象,最终都是为了转换成模板字符串
if (template) {
if (typeof template === 'string') {
//如果template是id选择器
if (template.charAt(0) === '#') {
//将id选择转化成模板
template = idToTemplate(template)
.....
}
} else if (template.nodeType) {//这个条件语句用于检查 template 是否是一个 DOM节点对象
//返回的是一个字符串,代表了template元素内部的 HTML 内容,将他作为模板
template = template.innerHTML
} else {
//抛出异常
.....
}
} else if (el) {
// 如果没有template属性,通过选择器获取元素内容(即获取tempalte)
template = getOuterHTML(el)
}

//此时template的值如果存在,一定是HTML字符串的形式,比如'<p>123</p>'
//然后再进行模板编译,得到渲染函数
if (template) {
/* istanbul ignore if */
......
const { render, staticRenderFns } = compileToFunctions(template, {
//省略.....
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
.......
}
}
this.mountComponent()
}

阅读上面代码,我们能得到以下结论:

  • 根元素不能是body或者html,也就是说el的指向不能是这两个元素。

  • $mount方法的工作流程就是:

  • 如果有render函数则直接调用mountComponent方法

  • 如果没有render函数,则尝试生成这个函数:

    • 如果有template属性,再考虑这个属性的值,我们期望是一个html字符串,但实际还可能是dom对象,我们把它转化成html字符串

    • 如果既没有render也没有template,那就必须有el,通过getOuterHTML(el)得到的值来代替template

    • 无论如何,最终template属性的值是一个html字符串

    • 解析,编译模板字符串,得到render函数,挂载到options

生成render函数,挂载到options上后,再调用mountComponent开始开始渲染

mountComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
// 如果没有获取解析的render函数,则会抛出警告
// render是解析模板文件生成的
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
....
}
// 执行beforeMount钩子
callHook(vm, 'beforeMount')

let updateComponent
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
updateComponent = () => {
....
//调用_render方法生成vnode
const vnode = vm._render()
....
//调用_update方法将虚拟dom转化成真实dom并放入页面
vm._update(vnode, hydrating)
.....
}
} else {
// 定义更新函数
updateComponent = () => {
// 实际调⽤是在lifeCycleMixin中定义的_update和renderMixin中定义的_render
vm._update(vm._render(), hydrating)
}
}
// 监听当前组件状态,当有数据变化时,更新组件
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
// 数据更新引发的组件更新
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false

if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}

方法主要执行在vue初始化时声明的_render_update方法,_render的作用主要是生成vnode

_render方法内部其实使用的是我们模板解析后得到的render函数,最终返回一个vnode。

_render方法源码

1
2
3
4
5
6
7
Vue.prototype._render = function (): VNode {
const vm: Component = this
// render函数来自于组件的option
const { render, _parentVnode } = vm.$options
....
return vnode
}

_update主要功能是调用patch,将vnode转换为真实DOM,并且更新到页面中。

手写一个简单的Vue

了解了Vue实例的挂载过程后,我们应该就能够模拟实现一个简单的Vue。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<body>
<div id="app">
<div><p>你好啊{{str}}</p></div>
<button @click="add">+</button>
{{num}}
<input type="text" v-model="num">
</div>
</body>
<script>
const app = new Vue({
el: '#app',
data: {
str: 'tom',
num: 0
},
methods:{
add(){
this.num++
}
}
})
</script>

模板解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Vue{
constructor(options){
this.$options = options
this.$data = options.data//假设是个对象,不是函数data()
this.$el = document.querySelector(options.el)
this.$compile(this.$el)
}
$compile(node) {
//childNodes返回这个dom元素的所有子节点,包括文本结点
node.childNodes.forEach(item => {
//nodeType 结点类型
// 1 表示元素结点
// 3 表示文本结点
if (item.nodeType === 1) {
//递归进行模板解析
this.$compile(item)
}
//如果是文本结点,进行模板替换
if (item.nodeType === 3) {
this.$replace(item)
}
})
}
$replace(item){
//非贪婪匹配
//通过textContent拿到文本结点的内容
item.textContent = item.textContent.replace(/\{\{(.*?)\}\}/g, (match, key) => {
// match是匹配到的具体的字符串
// key是匹配到的具体字符串删除具体字符的*剩余部分*,就是模板表达式中的变量
return this.$data[key.trim()]//必须返回用来替换的字符串
})
}
}

生命周期

vue2的生命周期函数很好实现,无非就是在特定时期调用特定的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Vue{
constructor(options){
this.$options = options
this.$data = options.data;
if(options.beforeCreate && typeof options.beforeCreate == 'function'){
options.beforeCreate.call(this);
}
if(options.created && typeof options.created == 'function'){
options.created.call(this);
}
if(options.beforeMount && typeof options.beforeMount == 'function'){
options.beforeMount.call(this);
}
this.$el= document.querySelector(options.el);
this.$compile(this.$el);
if(options.mounted && typeof options.mounted == 'function'){
options.mounted.call(this);
}
}
.....
}

添加事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Vue{
....
$compile(node) {
//childNodes返回这个dom元素的所有子节点,包括文本结点
node.childNodes.forEach(item => {
//nodeType 结点类型
// 1 表示元素结点
// 3 表示文本结点
if (item.nodeType === 1) {
//@click
if (item.hasAttribute('@click')) {
//最终还是使用了原生语法
item.addEventListener('click', (e) => {
// 如果此处使用的不是箭头函数,this的指向就是item了,我们要让this的指向变为vue实例。
// item.getAttribute('@click').trim() 获取@click属性的值
// 获取到这个属性的值,去除前后空格,假设是一个已有的方法名
// 调用这个方法,同时修改函数的指向,使它指向vue实例,同时传入事件对象e
const method = item.getAttribute('@click').trim()
this.$options.methods[method].call(this, e)
})
}
//递归
this.$compile(item)
}
//如果是文本结点
if (item.nodeType === 3) {
this.$replace(item)
}
})
}
.....
}

添加代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Vue{
....
//把this.$data中的数据代理到this上
$proxy() {
for (const key in this.$data) {
Object.defineProperty(this, key, {
//想要访问this.key,就返回this.$data[key]
get() {
return this.$data[key]
},
//想要修改this.key,就修改this.$data[key]
set(newVal) {
//数据未改变直接返回
if(this.$data[key]===newVal){
return
}
this.$data[key] = newVal
}
})
}
}
}

添加响应式

至此,我们修改数据,视图显然是不会更新的,即没有实现数据驱动视图更新的效果,简单来说,就是没有实现响应式

想要实现响应式,我们需要监听数据,并收集依赖

所谓收集依赖,就是要知道data中的某个属性,到底在哪些文本结点(或者计算属性)中使用过了,换句话说,就是这些文本结点依赖data中的那个数据;这个数据改变时,我们需要监听到这个数据的变化,然后通知依赖这个数据的文本结点(或者计算属性)更新内容。

依赖收集主要是在模板解析过程中进行的,在监听到数据的getter被触发的时候,收集它的依赖。

我们定义一个Watcher类来记录文本结点文本结点内部未编译前的字符串,和它依赖的数据(vm.key)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Watcher{
constructor(vm,key,node,str){
this.vm = vm
this.key = key
this.node = node
this.str = str
}
//更新
update(){
this.node.textContent = this.str.replace(/\{\{(.*?)\}\}/g, (match, key) => {
// match是匹配到的具体的字符串
// key是匹配到的具体字符串删除具体字符的*剩余部分*,就是模板表达式中的变量
return this.vm[this.key]
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
class Vue{
constructor(options){
this.$options = options
this.$data = options.data;//其实如果data是函数的话,还要调用函数。
this.$Watcher = {}
......
if(options.beforeCreate && typeof options.beforeCreate == 'function'){
options.beforeCreate.call(this);
}
//把this.$data中的数据代理到this上
this.$proxy()
//监听this.$data中的数据的变化
this.$observe()
if(options.created && typeof options.created == 'function'){
options.created.call(this);
}
.......
if(options.beforeMount && typeof options.beforeMount == 'function'){
options.beforeMount.call(this);
}
this.$el= document.querySelector(options.el);
//编译模板
this.$compile(this.$el);
......
if(options.mounted && typeof options.mounted == 'function'){
options.mounted.call(this);
}
......
}
$compile(node) {
//childNodes返回这个dom元素的所有子节点,包括文本结点和元素结点
node.childNodes.forEach(item => {
//nodeType 结点类型
// 1 表示元素结点
// 3 表示文本结点
if (item.nodeType === 1) {
//@click
if (item.hasAttribute('@click')) {
//本质基于原生js语法
item.addEventListener('click', (e) => {
const method = item.getAttribute('@click').trim()
this.$options.methods[method].call(this, e)
})
}
//如果是文本结点
if (item.nodeType === 3) {
this.$replace(item)
}
})
}
$replace(item){
item.textContent = item.textContent.replace(/\{\{(.*?)\}\}/g, (match, key) => {
// match是匹配到的具体的字符串
// key是匹配到的具体字符串删除具体字符的*剩余部分*,就是模板表达式中的变量
key = key.trim()
if(this.$Watcher[key]){
this.$Watcher[key].push(new Watcher(this,key,item,item.textContent))
}else{
this.$Watcher[key] = []
this.$Watcher[key].push(new Watcher(this,key,item,item.textContent))
}
return this[key]//返回用来替换的字符串,就是data中的值
})
}
//用来给数据添加代理
$proxy() {
for (const key in this.$data) {
Object.defineProperty(this, key, {
//想要访问this.key,就返回this.$data[key]
get() {
return this.$data[key]
},
//想要修改this.key,就修改this.$data[key]
set(newVal) {
//数据未改变直接返回
if(this.$data[key]===newVal){
return
}
this.$data[key] = newVal
}
})
}
}
//用来给数据添加监听,即监听this.$data中数据的改变
$observe(){
const vm = this
Object.keys(this.$data).forEach(key => {
//先取出值,防止无限递归
let value = this.$data[key]
Object.defineProperty(this.$data,key,{
get(){
return value
},
set(newVal){
//get,set中的this指向的是this.$data
if(value===newVal){
return
}
//如果newVal是obj,还要递归添加响应式。
if(typeof newVal == 'object'){
this.$observe(newVal)
}
value = newVal
//通知依赖更新
vm.$Watcher[key].forEach(w=>{
w.update()
})
}
}
//如果值是对象,则递归添加响应式
if(typeof value == 'object'){
this.$observe(value)
}
}
}
}

双向绑定

双向绑定并不等同于响应式了,这两个东西是有区别的。

响应性简单来讲就是当更改响应式数据时,视图会随即自动更新,即数据驱动视图更新。而实现这个功能的原理就是劫持(监听)数据收集依赖,当数据发生变化时,执行相应的依赖(副作用/更新视图)。

双向绑定是数据变化驱动视图更新,视图更新触发数据变化。其实就是v-model的功能,而我们知道v-model只是一个语法糖。因此如果要问双向绑定的原理,思路应该是如何实现这个语法糖

只需完善$compile方法和update方法。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class Watcher {
constructor(vm, key, node, str) {
this.vm = vm
this.key = key
this.node = node
this.str = str
}
//更新
update() {
//如果不是文本结点,即没有模板字符串,更新的是元素结点的属性值
if(this.str === undefined){
this.node.value = this.vm[this.key]
return
}
this.node.textContent = this.str.replace(/\{\{(.*?)\}\}/g, (match, key) => {
// match是匹配到的具体的字符串
// key是匹配到的具体字符串删除具体字符的*剩余部分*,就是模板表达式中的变量
return this.vm[this.key]
})
}
}
class Vue {
constructor(options) {
this.$options = options
this.$data = options.data;//只考虑data是对象的情况
this.$Watcher = {}
if (options.beforeCreate && typeof options.beforeCreate == 'function') {
options.beforeCreate.call(this);
}
//把this.$data中的数据代理到this上
this.$proxy()
//监听this.$data中的数据改变
this.$observe()
if (options.created && typeof options.created == 'function') {
options.created.call(this);
}
if (options.beforeMount && typeof options.beforeMount == 'function') {
options.beforeMount.call(this);
}
this.$el = document.querySelector(options.el);
//模板解析
this.$compile(this.$el);
if (options.mounted && typeof options.mounted == 'function') {
options.mounted.call(this);
}
}
$compile(node) {
//childNodes返回这个dom元素的所有子节点,包括文本结点
node.childNodes.forEach(item => {
//nodeType 结点类型
// 1 表示元素结点
// 3 表示文本结点
if (item.nodeType === 1) {
//@click
if (item.hasAttribute('@click')) {
//本质还是使用了原生语法
item.addEventListener('click', (e) => {
// 如果此处使用的不是箭头函数,this的指向就是item了,我们要让this的指向变为vue实例。
// item.getAttribute('@click').trim() 获取@click属性的值
// 获取到这个属性的值,去除前后空格,假设是一个已有的方法名
// 调用这个方法,同时修改函数的指向,使它指向vue实例,同时传入事件对象e
const method = item.getAttribute('@click').trim()
this.$options.methods[method].call(this, e)
})
}
//v-model
if(item.hasAttribute('v-model')){
const key = item.getAttribute('v-model').trim()
item.value = this[key] //赋值
if(this.$Watcher.hasOwnProperty(key)){
this.$Watcher[key].push(new Watcher(this, key, item))
}else{
this.$Watcher[key] = []
this.$Watcher[key].push(new Watcher(this, key, item))
}
item.addEventListener('input',()=>{
this[key] = item.value
})
}
//递归
this.$compile(item)
}
//如果是文本结点
if (item.nodeType === 3) {
this.$replace(item)
}
})
}
$replace(item) {
//非贪婪匹配
//通过textContent拿到文本结点的内容
//如果文本节点中没有使用模板字符串,则匹配不到任何内容,回调函数中的代码也不会执行。
item.textContent = item.textContent.replace(/\{\{(.*?)\}\}/g, (match, key) => {
// match是匹配到的具体的字符串
// key是匹配到的具体字符串删除具体字符的*剩余部分*,就是模板表达式中的变量
key = key.trim()
if (this.$Watcher[key]) {
this.$Watcher[key].push(new Watcher(this, key, item, item.textContent))
} else {
this.$Watcher[key] = []
this.$Watcher[key].push(new Watcher(this, key, item, item.textContent))
}
return this[key]//返回用来替换的字符串,就是data中的值
})
}
//用来给数据添加代理
$proxy() {
for (const key in this.$data) {
Object.defineProperty(this, key, {
//想要访问this.key,就返回this.$data[key]
get() {
return this.$data[key]
},
//想要修改this.key,就修改this.$data[key]
set(newVal) {
//数据未改变直接返回
if (this.$data[key] === newVal) {
return
}
this.$data[key] = newVal
}
})
}
}
//用来给数据添加监听,即监听this.$data中数据的改变
$observe() {
const vm = this
Object.keys(this.$data).forEach(key => {
//先取出值,防止无限递归
let value = this.$data[key]
Object.defineProperty(this.$data, key, {
get() {
return value
},
set(newVal) {
//set函数内部的this指向this.$data
if (value === newVal) {
return
}
if(typeof newVal == 'object'){
this.$observe(newVal)
}
value = newVal
vm.$Watcher[key].forEach(w => {
w.update()
})
}
})
})
//如果值是对象,则递归添加响应式
if(typeof value == 'object'){
this.$observe(value)
}
}
}
const app = new Vue({
el: '#app',
data: {
str: 'tom',
num: 0
},
methods:{
add(){
this.num++
}
}
})

Vue.observable你有了解过吗?说说看

Vue.observable,让一个对象变成响应式数据。Vue 内部会用它来处理 data 函数返回的对象

Vue 2.x 中,被传入的对象会直接被 Vue.observable 变更,它和被返回的对象是同一个对象,不过在原来的基础上添加了响应式,这一点,看看前面对defineReactive方法的介绍就很容易理解了。

Vue 3.x 中,则会返回一个可响应的代理对象,而对源对象直接进行变更仍然是不可响应的,因为在vue3中响应式的实现是基于Proxy这个构造函数,传入一个对象,会返回一个新的代理对象,对代理对象的修改会映射到源对象。

使用场景

创建一个js文件

1
2
3
4
5
6
7
// 引入vue
import Vue from 'vue
// 创建state对象,使用observable让state对象可响应
export let state = Vue.observable({
name: '张三',
'age': 38
})

.vue文件中直接使用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<template>
<div>
姓名:{{ name }}
年龄:{{ age }}
<button @click="changeName('李四')">改变姓名</button>
<button @click="setAge(18)">改变年龄</button>
</div>
</template>
import { state, mutations } from '@/store
export default {
// 在计算属性中拿到值
computed: {
name() {
return state.name
},
age() {
return state.age
}
},
// 调用mutations里面的方法,更新数据
methods: {
changeName(name) {
state.name = name
},
setAge(age) {
state.age = age
}
}
}

详细解释

依赖收集是组件初始化过程中,模板解析时候的工作,组件模板解析的时候,如果使用到了某个响应式对象的某个属性,就会new一个watcher,存储到Dep.target中,然后取值的时候会触发gettergetter内部会判断Dep.target是否为空,不为空则收集依赖,把这个watcher取出来,push到这个属性(key)的deps(依赖数组)中。然后某个属性值修改的时候就会触发对应的setter,通知这些依赖的watcher更新内容,即调用依赖这个属性(key)的watcherupdate方法。

所以说Vue.observable只能给数据添加响应式,但是想要实现数据修改,依赖这些数据的组件也重新渲染,就需要在模板解析过程中收集依赖。

说说你对slot的理解?slot使用场景有哪些?

slot的作用就是用来自定义组件内部的结构

slot可以分来以下三种:

  • 默认插槽
  • 具名插槽
  • 作用域插槽

默认插槽

子组件用<slot>标签,来确定渲染的位置,标签里面可以放DOM结构,当父组件没有往插槽传入内容,标签内DOM结构,就会显示在页面

父组件在使用的时候,直接在子组件的标签内写入内容即可

子组件Child.vue,使用slot标签占位,标签体内的结构是默认结构

1
2
3
4
5
<template>
<slot>
<p>插槽后备的内容</p>
</slot>
</template>

父组件向子组件传递结构,只需要在子组件标签体内写结构就好了

1
2
3
<Child>
<div>默认插槽</div>
</Child>

父组件给子组件传入的自定义结构,可以在子组件的this.$slots属性中拿到。

具名插槽

默认插槽形如

1
2
3
<slot>
<p>插槽后备的内容</p>
</slot>

当我们给slot标签添加name属性,默认插槽就变成了具名插槽

当我们需要在子组件内部的多个位置使用插槽的时候,为了把各个插槽区别开,就需要给每个插槽取名。

同时父组件传入自定义结构的时候,也要指明是传递给哪个插槽的,形象的来说,就是子组件挖了多个坑,然后父组件来这些填坑,需要把具体的结构填到具体的哪个坑。

子组件Child.vue

1
2
3
4
<template>
<slot>插槽后备的内容</slot>
<slot name="content">插槽后备的内容</slot>
</template>

父组件

1
2
3
4
5
<child>
<template v-slot:default>具名插槽</template>
<!-- 具名插槽⽤插槽名做参数 -->
<template v-slot:content>内容...</template>
</child>

template标签是用来分割,包裹自定义结构的。v-slot属性用来指定这部分结构用来替换哪个插槽,所以v-slot指令是放在template标签上的,要注意的是如果想要将某部分结构传递给指定的插槽,因该使用v-slot:xxx,而不是v-slot='xxx'

v-slot:default可以简化为#defaultv-slot:content可以简化成#content

作用域插槽

子组件在slot标签上绑定属性,来将子组件的信息传给父组件使用,所有绑定的属性(除了name属性),都会被收集成一个对象,被父组件的v-slot属性接收。

子组件Child.vue

1
2
3
4
5
<template> 
<slot name="footer" testProps="子组件的值">
<h3>没传footer插槽</h3>
</slot>
</template>

父组件

1
2
3
4
5
6
7
8
9
<child> 
<!-- 把v-slot的值指定为作⽤域上下⽂对象 -->
<template v-slot:footer="slotProps">
来⾃⼦组件数据:{{slotProps.testProps}}
</template>
<template #footer="slotProps">
来⾃⼦组件数据:{{slotProps.testProps}}
</template>
</child>

可以通过解构获取v-slot={user},还可以重命名v-slot="{user: newName}"和定义默认值v-slot="{user = '默认值'}"

所在slot中也存在’’双向数据传递’’,父组件给子组件传递页面结构,子组件给父组件传递子组件的数据。

你有写过自定义指令吗?自定义指令的应用场景有哪些?

什么是指令

vue中提供了一套为数据驱动视图更为方便的操作,这些操作被称为指令系统。简单的来说,指令系统能够简化dom操作,帮助方便的实现数据驱动视图更新

我们看到的v-开头的行内属性,都是指令,不同的指令可以完成或实现不同的功能

除了核心功能默认内置的指令 (v-modelv-show),Vue 也允许注册自定义指令

指令使用的几种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//会实例化一个指令,但这个指令没有参数 
v-xxx

//将值传到指令中
v-xxx="value"

//将字符串传入到指令中,如v-html="'<p>内容</p>'"
v-xxx="'string'"

//传参数(arg),如v-bind:class="className"
v-xxx:arg="value"

//使用修饰符(modifier)
v-xxx:arg.modifier="value"

注意:指令中传入的都是表达式,无论是不是自定义指令,比如v-bind:name = 'tom',传入的是tom这个变量的值,而不是tom字符串,除非写成"'tom'",传入的才是字符串。

如何实现

关于自定义指令,我们关心的就是三大方面,自定义指令的定义,自定义指令的注册,自定义指令的使用

自定义指令的使用方式和内置指令相同,我们不再研究,其中的难点就是定义自定义指令部分。

注册自定义指令

注册一个自定义指令有全局注册局部注册两种方式

全局注册主要是通过Vue.directive方法进行注册

Vue.directive第一个参数是指令的名字(不需要写上v-前缀),第二个参数可以是对象数据,也可以是一个指令函数

1
2
3
4
5
6
7
8
//全局注册一个自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus() // 页面加载完成之后自动让输入框获取到焦点的小功能
}
})

局部注册通过在组件配置对象中设置directives属性

1
2
3
4
5
6
7
8
directives: {
focus: {
// 指令的定义
inserted: function (el) {
el.focus() // 页面加载完成之后自动让输入框获取到焦点的小功能
}
}
}

然后就可以使用

1
<input v-focus />

在vue3中,局部注册的语法就不同了。如果混合使用选项式api,就可以像vue2一样借助directives属性解决,如果使用的是setup语法糖写法,就需要遵守如下语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<div>
<!-- 使用局部注册的自定义指令 -->
<p v-highlight="'yellow'">This text will be highlighted in yellow</p>
<input type="text" v-focus />
</div>
</template>

<script setup>
import { onMounted, ref } from 'vue'
import { directive } from 'vue'

// 定义一个高亮指令
const highlight = directive({
mounted(el, binding) {
el.style.backgroundColor = binding.value;
}
})

// 定义一个聚焦指令
const focus = directive({
mounted(el) {
el.focus();
}
})
</script>

导入directive函数,传入自定义指令,完成组件的局部注册。

定义自定义指令

自定义指令本质就是一个包含特定钩子函数的js对象

在vue2中,这些常见的钩子函数包括:

  • bind()

    只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置,此时无法通过el拿到父级元素,也就是el.parentNode为空,但是也已经能拿到绑定的dom元素了。

  • inserted()

    绑定指令的元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中,因为父元素可能还没插入文档中呢),此时可以通过el.parentNode拿到父级元素

  • mounted()

    指令绑定的元素被插入到文档中之后

  • update()

    传入指令的值改变后触发

  • unbind()

    只调用一次,指令与元素解绑时调用

注意:上述钩子函数在vue3中并不都有效,vue3中的自定义指令钩子函数和生命周期函数一致,具体见官方文档,https://cn.vuejs.org/guide/reusability/custom-directives#directive-hooks

所有的钩子函数的参数都有以下:

  • el:指令所绑定的元素,可以用来直接操作 DOM,省去了手动捕获dom的步骤

  • binding

    一个对象,包含以下property

    • name:指令名,不包括 v- 前缀。
    • value:传入指令的表达式的值,例如:v-my-directive="1 + 1" 中,绑定值为 2
    • oldValue:指令绑定的前一个值,仅在 updatecomponentUpdated 钩子中可用。无论值是否改变都可用。
    • expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1",又比如v-for="(value, key, index) in obj",传入的表达式为"(value, key, index) in obj"
    • arg传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo",又比如v-bind:class = "['box']"的参数为class
    • modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }
  • vnodeVue 编译生成的虚拟节点

  • oldVnode:上一个虚拟节点,仅在 updatecomponentUpdated 钩子中可用

应用场景

给某个元素添加节流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 1.设置v-throttle自定义指令
Vue.directive('throttle', {
bind: (el, binding) => {
let throttleTime = binding.value; // 节流时间
if (!throttleTime) { // 用户若不设置节流时间,则默认2s
throttleTime = 2000;
}
let timer;
//el是绑定指令的元素
el.addEventListener('click', event => {
if (!timer) { // 第一次执行
//开启定时器,占用临界资源
timer = setTimeout(() => {
//一定事件后,释放资源
timer = null;
}, throttleTime);
//同时绑定的另一个监听器也被调用,触发sayHello函数
} else {
//如果在前throttleTime的时间内已经点击过了,则阻止目标元素绑定的监听器被调用,也就是说,sayHello不会被调用
//它不仅会阻止事件继续沿 DOM 树传播,还会阻止在同一阶段内其他监听器的执行,包括目标阶段的监听器。
event && event.stopImmediatePropagation();
}
}, true);//捕获触发,触发的顺序在冒泡触发之前
},
});
// 2.为button标签设置v-throttle自定义指令
<button @click="sayHello" v-throttle>提交</button>

Vue常用的修饰符有哪些有什么应用场景

修饰符是什么

Vue中,修饰符是用来修饰Vue中的指令的,它处理了许多DOM事件的细节,让我们不再需要花大量的时间去处理这些烦恼的事情,而能有更多的精力专注于程序的逻辑处理。

vue中修饰符分为以下五种:

  • 表单修饰符
  • 事件修饰符
  • 鼠标按键修饰符
  • 键值修饰符
  • v-bind修饰符

修饰符的具体作用

表单修饰符

在我们填写表单的时候用得最多的是input标签,指令用得最多的是v-model

关于表单的修饰符有如下:

  • lazy
  • trim
  • number

lazy

在我们填完信息,光标离开标签的时候,才会将值赋予给value,也就是在change事件之后再进行信息同步

1
2
<input type="text" v-model.lazy="value">
<p>{{value}}</p>

trim

自动过滤用户输入的首尾空格字符,而中间的空格不会过滤

1
<input type="text" v-model.trim="value">

number

自动将用户的输入值转为数值类型,但如果这个值无法被parseFloat解析,则会返回原来的值

1
<input v-model.number="age" type="number">

事件修饰符

  • stop:阻止事件冒泡,等在传入的回调函数中添加 event.stopPropagation()

    1
    2
    3
    4
    5
    6
    <button @click.stop="handleClick">点击不会冒泡</button>
    //等效于
    const handleClickWithStop = (event) => {
    event.stopPropagation(); // 手动阻止冒泡
    // 其他业务逻辑
    };
  • prevent:阻止默认行为,等同于在传入的回调函数中添加 event.preventDefault()

    1
    <form @submit.prevent="handleSubmit">提交表单不会刷新页面</form>
  • capture:使用事件捕获模式(默认是冒泡模式)

    1
    <div @click.capture="parentClick">父级先触发</div>
  • self:仅当事件从元素本身(而非子元素)触发时执行

    1
    <div @click.self="onlySelfClick">点击子元素不触发</div>
  • once:事件只触发一次,之后自动移除对该事件的监听,避免因长期持有未使用的监听函数导致内存泄漏、

    1
    <button @click.once="oneTimeAction">仅首次点击有效</button>

    其实在原生dom事件中,实现这个效果也是非常简单的,只需要在第三个参数传入{ once: true },手动通过removeEventListener还是比较消耗精力的,不过灵活度更大。

    1
    element.addEventListener('click', handler, { once: true });
  • passive:提升滚动性能,不与 prevent 同时使用

    1
    <div @scroll.passive="onScroll">滚动更流畅</div>

    当监听 touchstarttouchmovewheel(滚动)等高频事件时,浏览器的默认行为是:等待事件处理函数执行完毕

    再决定是否执行默认行为(如滚动页面),如果事件处理函数中存在耗时操作(如复杂计算),会导致 滚动卡顿,因为浏览器必须等待函数执行完毕,才能滚动页面(默认行为)。

    passive 修饰符的作用,是通过将事件监听器标记为 被动模式(Passive),本质是向浏览器承诺:
    ​“此事件处理函数不会调用 event.preventDefault()”​,从而允许浏览器 ​立即触发默认行为,无需等待函数执行。

    Vue 3 的 .passive 修饰符对应原生 addEventListener{ passive: true } 配置:

    1
    2
    // Vue 编译后的等效代码
    element.addEventListener('scroll', handler, { passive: true });

    .passive 向浏览器承诺 不会阻止默认行为,而 .prevent 的作用是 主动阻止默认行为,二者语义冲突,所以不能同时使用。

Vue中组件和插件有什么区别

组件是什么

在vue中,组件就是能实现部分功能的html,css,js代码的集合。

优势

  • 降低整个系统的耦合度

    在保持接口不变的情况下,我们可以替换不同的组件快速完成需求,例如输入框,可以替换为日历、时间、范围等组件作具体的实现

  • 提高代码的可维护性,和可复用性

    由于每个组件的职责单一,并且组件在系统中是被复用的。

插件是什么

插件通常用来为 Vue 添加全局功能,比如通过全局混入来添加一些组件选项。如vue-router

区别

两者的区别主要表现在以下几个方面:

  • 编写形式
  • 注册形式

编写形式

组件

编写一个组件,可以有很多方式,我们最常见的就是vue单文件的这种格式,每一个.vue文件我们都可以看成是一个组件。

插件

vue插件就是一个实现了 install 方法的对象。这个方法的第一个参数是 Vue 构造函数,第二个参数是一个可选的选项对象(options)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MyPlugin.install = function (Vue, options) {
// 1. 添加全局方法或 property
Vue.myGlobalMethod = function () {
// 逻辑...
}
// 2. 添加全局资源
Vue.directive('my-directive', {
bind (el, binding, vnode, oldVnode) {
// 逻辑...
}
...
})
// 3. 注入组件选项
Vue.mixin({
created: function () {
// 逻辑...
}
...
})
// 4. 添加实例方法
Vue.prototype.$myMethod = function (methodOptions) {
// 逻辑...
}
}

注册形式

组件注册

vue组件注册主要分为全局注册局部注册

局注册通过Vue.component方法,第一个参数为组件的名称,第二个参数为传入的配置项

1
Vue.component('my-component-name', { /* ... */ })

局部注册只需在用到的地方通过components属性注册一个组件

1
2
3
4
5
6
7
const component1 = {...}// 定义一个组件

export default {
components:{
component1//局部注册
}
}

在vue3中的组件注册

全局注册:

1
2
3
4
5
6
import { createApp } from 'vue';
import MyComponent from './MyComponent.vue'; // 引入你的组件

const app = createApp({});
app.component('MyComponent', MyComponent); // 全局注册组件
app.mount('#app');

局部注册:

1
2
3
4
5
6
7
8
9
10
<script>
import MyComponent from './MyComponent.vue';

export default {
components: {
MyComponent // 局部注册组件
}
}
</script>

或者

1
2
3
4
5
6
7
8
9
10
<script setup>
import MyComponent from './MyComponent.vue'; // 引入你的组件
</script>

<template>
<div>
<!-- 使用局部注册的组件 -->
<MyComponent />
</div>
</template>

<script setup> 中导入的组件会自动注册并在模板中可用,无需显式地在 components 选项中列出它们。

插件注册

插件的注册通过Vue.use()的方式进行注册,第一个参数为插件的名字,第二个参数是可选择的配置项

1
2
3
4
5
6
Vue.use(插件名字[,options])
Vue.use = function(plugin,options){
//this指向Vue构造函数
//在use方法内部,会调用插件的install方法
plugin.install(this,options)
}

注册插件的时候,需要在调用 new Vue() 启动应用之前完成,Vue.use会自动阻止多次注册相同插件,只会注册一次。

Vue组件通信的方式有哪些

vue中,每个组件之间的都有独自的作用域,组件间的数据是无法共享的,但实际开发工作中我们常常需要让组件之间共享数据,这也是组件通信的目的,要让它们互相之间能进行通讯,这样才能构成一个有机的完整系统。

组件间通信的分类

  • 父子组件之间的通信
  • 兄弟组件之间的通信
  • 祖孙与后代组件之间的通信
  • 非关系组件间之间的通信

组件间通信的方案

props传递数据

适用场景:父组件传递数据给子组件,即父子组件之间的通信

父组件通过给子组件标签添加属性,来传递值,子组件设置props属性,接收父组件传递过来的参数,同时还能限制父组件传递过来的数据的类型,还能设置默认值。

1
<Children name="jack" age=18 />  
1
2
3
4
5
6
7
8
9
10
11
//Children.vue
props:{
 // 字符串形式
 name:String // 接收的类型参数
 // 对象形式
age:{  
     type:Number// 接收的类型为数值
     defaule:18,  // 默认值为18
     require:true // age属性必须传递
 }
}

注意:

  • props中的数据是父组件的,子组件不能直接修改,遵循”谁的数据谁来维护”的原则。
  • 子组件标签的所有属性中,未被子组件接收(props中未声明)的数据,也能在this.$attr,即组件实例的属性中拿到,因为未被接受的属性,就会被当作组件自身的普通属性。

再问大家一个问题,为什么父组件中的数据更新,子组件中通过props接收的数据也会随之改变?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//子组件
<template>
<div class="hello">
<span>age:{{age}}</span>
</div>
</template>

<script>
export default {
name: 'Child',
props: {
age: Number
},
updated(){
console.log('更新了')
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//父组件
<template>
<div>
<!-- 向子组件传递一个基本数据类型 -->
<Child :age="age">
</Child>
<button @click="add('age')">age++</button>
</div>
</template>

<script>
import Child from '@/components/Child.vue';
export default {
data(){
return{
age:1,
}
},
components:{
Child
},
methods:{
add(){
this.age++
}
}
}
</script>

父组件的模板,在模板编译的时候,会被解析成一个render函数,这一点我们在前面已经介绍过了,在上述例子中,父组件的模板解析成render函数大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function render(createElement) {
return createElement(
'div', // 根元素 div
[ // 子节点数组
createElement('Child', { // 子组件 Child,绑定 props.age
props: {
age: this.age // 传递父组件的 age 属性
}
}),
// 按钮元素,绑定 click 事件
createElement('button', {
on: {
click: () => this.add('age') // 触发 add('age') 方法
}
}, 'age++')
]
);
}

可以看出,在父组件的模板render函数中,访问了父组件实例的age属性,赋值给子组件的props.age,这个过程中触发age属性的getter于是收集父组件自身的render函数为依赖(渲染Watcher),然后子组件初始化的时候,会调用initProps方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//传入的第二个参数,是子组件中的props属性的值(props配置对象)
function initProps(vm, propsOptions) {
//拿到子组件声明并接收到的所有props数据(不包括普通标签属性)
const propsData = vm.$options.propsData || {};
//创建一个空对象,挂载到组件实例_props属性上
const props = vm._props = {};
// 遍历props配置对象
for (const key in propsOptions) {
// 使用propsOptions校验propsData
const value = validateProp(key, propsOptions, propsData, vm);
// 组件接收的属性,会被添加到vm._props上,并添加响应式
defineReactive(props, key, value);
// 代理到实例(this),然后就能直接通过this访问
if (!(key in vm)) {
proxy(vm, '_props', key);
}
}
}

可以看出,父组件传递了,且子组件通过props接收的数据,会被存储在vm.$options.propsData,然后子组件初始化的时候(调用initProps的时候),会将通过props接收的数据添,加响应式,并代理到vm上,缩短访问路径。

上述例子中,父组件传递给子组件的值,只不过是this.age,是一个普通数据类型,压根不是响应式数据,这种传递会导致响应式丢失,触发getter的位置,也是在父组件渲染函数内,子组件压根就没被age属性收集为依赖,后续是子组件自己把age属性添加到vm_props并添加响应式的(后续又代理到vm),既然在父组件的age属性并没有收集子组件为依赖,为什么父组件更新age属性,子组件也能接收到最新的值呢?

因为父组件中的age属性改变,会触发对应的setter,然后通知依赖更新,其中的依赖就包括父组件渲染Watcher,然后父组件渲染你Watcher会调用run方法,这个方法会调用render函数,重新给子组件的props赋值,后续子组件中this_props存储的就是最新的值。

参考文章:【Vue原理】Props - 白话版 - 知乎

$emit 触发自定义事件

适用场景:子组件传递数据给父组件(父子组件通信)

子组件通过$emit触发自定义事件,$emit第一个参数为自定义的事件名,第二个参数为传递给父组件的数值

父组件在子组件上绑定事件监听,通过传入的回调函数拿到子组件的传过来的值。

1
2
//Children.vue
this.$emit('add', good)
1
2
//Father.vue
<Children @add="cartAdd(val)" />

要注意的是,给组件添加的事件监听是自定义事件,因为组件标签不是原生标签,无法添加原生事件监听,也就没有原生事件对象,所以传递给回调函数的是子组件传递过来的值,而不是原生dom事件。

在vue2中,我们只要给父组件传递数据,并给对应的属性添加sync修饰符,就能省去在给组件标签添加事件监听,书写回调逻辑,同步父组件数据的代码,在vue3中,这一功能则是通过v-model实现的,更多介绍参考本博客内的《vue》一文。

ref

在 Vue 2 中,this.$refs 是一个对象,它包含了所有通过 ref 属性注册的 DOM 元素组件实例。你可以使用 this.$refs 来直接访问这些dom元素或组件实例,从而进行操作,如获取DOM节点、调用子组件实例的方法,获取数据等。

注意:this.$refs 只能在父组件中,用来引用通过 ref 属性标记的子组件或 DOM 元素

1
2
<Children ref="foo" />  
this.$refs.foo // 获取子组件实例,通过子组件实例我们就能拿到对应的数据

同时,子组件也可通过this.$parent拿到父组件实例

EventBus(事件总线)

使用场景:兄弟组件传值

  • 通过共同祖辈$parent或者$root搭建通信

    兄弟组件

    1
    this.$parent.on('add',this.add)   

    另一个兄弟组件

    1
    this.$parent.emit('add')   

本质就是要找到一个两个兄弟组件都能访问到的vue实例,在这个实例上注册事件监听,同时也在这个实例上触发事件,本质和props,emit是一样的。这个vue实例的作用好像连接这两个组件的管道,通过这个Vue实例来通行。

provide 与 inject

跨层级传递数据,传递方向是单向的,只能顶层向底层传递。

祖先组件定义provide属性,返回传递的值,在后代组件通过inject接收祖先组件传递过来的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 普通类型是响应式的复杂类型则不是,这和vue2数据响应式的实现方式(递归添加响应式)有关
// 这里丢失响应式的原因,和解构响应式对象失去响应式的原因是一样的
export default {
data() {
return {
color: 'red', // 普通类型(非响应式)
userInfo: { name: 'John', age: 30 } // 复杂类型(响应式)
};
},
provide() {
//使用data或者computed中的数据
return {
color: this.color, // 非响应式
userInfo: this.userInfo, // 响应式
};
}
};
1
2
3
4
5
6
7
export default {
// 如果多个祖先组件都提供了同名的属性,那么最接近的祖先组件提供的属性,会被优先使用(就近原则)。
inject: ['color','userInfo'],//书写格式太像props了
created () {
console.log(this.color, this.userInfo)
}
}

Vuex

关于vuex的介绍,详见vue | 三叶的博客

SPA

什么是SPA,和MPA有什么区别?

  • SPA指的是只有一个页面的web应用程序,所有必要的代码(HTMLJavaScriptCSS)都通过单个页面的加载而被加载(这样首屏加载速度就很慢),或者根据需要(通常是为响应用户操作),动态装载适当的资源,并添加到页面,页面在任何时间点都不会重新加载,也不会将控制转移到其他页面。

  • MPA(多页面应用程序)指的是有多个页面的web应用程序

  • SPA通过js操作dom,来局部更新页面内容;而MPA是通过页面切换,来实现整页的刷新,整页刷新就需加载整个页面所有资源,并重新渲染页面,速度慢;

  • SPA刷新速度更快,用户体验更好,同时把页面渲染工作交给客户端,减轻了服务端的压力。

  • 缺点是不利于搜索引擎优化(SEO),首屏加载速度较慢,当然这些问题都是可以解决的。

面试官:你对SPA单页面的理解,它的优缺点分别是什么?如何实现SPA应用呢 | web前端面试 - 面试官系列

如何实现SPA

SPA是通过hash路由或者history路由实现的,问如何实现SPA,其实就是在询问这两种路由是如何实现,关于这一点,可以参考后文。

如何提高首屏加载速度?

首屏加载时间,指的是浏览器从响应用户输入网址,到首屏内容渲染完成的时间,此时整个网页不一定要全部渲染完成,但需要展示当前视窗需要的内容。

首屏加载慢的原因

  • 网络延时问题
  • 资源文件体积是否过大
  • 资源是否重复发送请求去加载了
  • 加载脚本的时候,渲染内容堵塞了

提高首屏加载速度的方法

  • 使用路由懒加载

    路由懒加载本质就是异步加载js,css文件,或者说按需加载js,css文件。

    对于非首屏组件,使用路由懒加载,当需要访问这些组件的时候,再加载对应的资源。

    开发单页面应用程序时,只有一个html页面,打包后也只有一个index.html页面,其他所谓的页面,都是通过 JavaScript 动态地修改DOM来实现的。开发过程中,一个页面对应一个或者多个组件,在打包后,每个组件都会转化成对应的cssjs代码,其中的js代码不光包括业务逻辑,也负责修改dom,构建页面。

    如果使用路由懒加载,我们可以观察到,打包后的js,css文件数量变多了,每个文件的体积也变小了,是因为使用懒加载的组件都被打包成独立的css,js文件了。这样,index.html引入的的jscss文件的体积也会变小,因为只包含首屏组件需要的js,css代码。

  • 从静态资源入手,减少加载时间

    • 缓存静态资源

      对于已经请求过的资源,再次请求直接使用缓存。比如我们每天都要刷b站,可以观察到,B站的页面样式改变的频率是比较低的,如果我们每次登录b站,都要重新请求这些css样式文件,然后再解析渲染,就比较慢了,但是如果我们缓存这些css文件,下次就可以省去加载这些资源的时间,从而提高首屏加载速度。

      再比如,对于首屏固定不变的图片,如果我们缓存了,下次也可以直接使用。

    • 压缩图片等静态资源的大小

      这一点是显而易见的,压缩静态资源的大小,我们加载这些资源的时间就变少了,从而提高了首屏加载速度。我在部署自己的博客前,也会先把将要上传的图片,样式表,js文件,html文件等静态资源统一压缩,再上传,以求提高首屏加载速度。在实际开发过程中,这个功能通常是由webpack等模块化打包工具自动实现的。

    • 内联首屏关键css

      关于这一点,可以参考《前端面试—css》中的css性能优化部分。本质就是省去加载首屏关键css的时间。

  • 使用服务端渲染SSR

    将首页的html结构的拼接工作交给后端服务器,关于服务端渲染的介绍参考后文。

    对于vue,推荐使用nuxt.js

如何提高SPA的SEO

服务端渲染SSR

指由服务端完成页面的 HTML结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。

这意味着我们需要和服务器打交道

传统web开发,一般就是多页面应用程序,每个页面的html结构都在服务端拼接好。

单页面应用程序(SPA)通过浏览器执行js代码来实现页面的html结构的替换,拼接。

有利于SPA的SEO

使用服务端渲染,返回的页面就已经包含了一定的页面结构,能够被搜索引擎爬取。

提高的首屏渲染速度

使用服务端渲染,将首屏结构交给服务端来拼接,这样不必等待页面所有js加载完成,就可以看到首屏视图。

简单实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{title}}</title>
{{{metas}}}
</head>
<body
<!--下面的注释最终会被渲染好的模板内容替代注意不能有空格-->
<!--vue-ssr-outlet-->
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//因为是在服务端运行的代码,所以使用的是cjs语法
const express = require('express')
const app = express()
const Vue = require('vue')
const vueServerRenderer = require('vue-server-renderer')
const fs = require('fs')
// 以utf-8的格式,同步读取模板html文件,返回一个string
const template = fs.readFileSync('./index.html','utf-8')
// console.log(typeof template)
// 根据传入的html模板,创建一个renderer,渲染好的模板会本放入html模板的指定位置
const renderer = vueServerRenderer.createRenderer({template})
app.get('*', (req, res)=>{
const vue = new Vue({
data:{
url:req.url
},
template:"<div>{{url}}</div>"
})
//更多html模板配置参数
const context = {
title:'Vue SSR',
metas:`<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">`
}
//调用renderer对象的renderToString方法
//第一个参数传入vue实例,第二个参数传入模板html文件的更多配置参数,第三个参数传入一个渲染成功后触发的回调函数
//回调函数的第一个参数是一个错误对象,第二个参数才是渲染好后的html字符串
renderer.renderToString(vue, context ,(err,html)=>{
if(err){
//链式调用
res.status(500).end('服务端错误')
}else{
res.end(html)
}
})
})
app.listen('8080',()=>{
console.log('服务器开启成功')
})

hash路由和history路由的实现原理,二者有什么区别?

哈希路由(Hash-based Routing)和 History 路由(History API-based Routing)是前端路由的两种常见实现方式,它们用于在单页面应用程序 (SPA) 中模拟多页面体验,而无需重新加载整个页面。

hash路由

是什么

  • 前端路由被放到urlhash部分,即url中#后面的部分。
  • 哈希值改变也不会触发页面重新加载,但是会产生历史记录。
  • 浏览器不会将哈希值发送到服务器,因此无论哈希值如何变化,刷新页面,服务器只会返回同一个初始 HTML 文件。

优缺点

  • 不需要服务器配置支持,因为哈希值不会被发送给服务器。
  • 兼容性好,几乎所有浏览器都支持哈希变化事件。
  • URL 中包含显眼的 # 符号,可能影响美观。
  • 前端路由部分十分明确,方便部署,可以部署在服务器的任何位置

如何做

可以直接设置 window.location.hash 属性来改变 URL 中的哈希部分,改变 window.location.hash 不会触发页面刷新,但它会添加一个新的历史记录条目

前端 JavaScript 监听 hashchange 事件来检测哈希的变化,并根据新的哈希值更新页面内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Router {
constructor() {
//存储当前的hash值
this.currentHash = ''
//存储路由
this.routes = {}
//页面初次加载的时候,获取当前路由,根据当前路由执行对应的回调函数
window.addEventListener('load', () => {
//如果hash值为"" ,则修改hash值为 '/',否则直接执行对应的回调函数
this.currentHash = location.hash.slice(1)
if (!this.currentHash) {
this.push('/')
} else {
this.callback(this.currentHash)
}
})
window.addEventListener('hashchange', () => {
//hash值改变了,及时更新this.currentHash
this.currentHash = location.hash.slice(1)
//调用对应的回调函数
this.callback(this.currentHash)
})
}
//用来根据当前hash路由,执行对应的回调函数
callback(path) {
const callbackFunc = this.routes[path]
if (callbackFunc) {
callbackFunc()
} else {
console.log('当前hash路由没有注册对应的回调函数')
}
}
//用来注册路由,对应的回调函数--
route(path, callback) {
this.routes[path] = callback
}
//修改当前页面的hash值,模拟路由跳转,这一操作会触发hashchange,然后就会执行对应的回调函数
push(path) {
location.hash = path
}
}
// 将创建的实例挂载到window上,成为全局变量
window.miniRouter = new Router();
// 注册路由
miniRouter.route('/', () => console.log('page1'))
miniRouter.route('/page2', () => console.log('page2'))
// 模拟导航操作
miniRouter.push('/'); // 应该输出 'page1'
miniRouter.push('/page2'); // 应该输出 'page2'

history路由

是什么

使用标准的路径形式,例如 http://example.com/page1前端路由被放到url中的资源路径部分

优缺点

  • 没有显眼的#号,更为美观

  • 搜索引擎可以直接抓取完整 URL(如 /about),有利于 SEO 优化。

  • 非常适合用来做服务端渲染,提高页面的SEO:History 路由的 URL 结构,与传统多页应用的URL结构一致,这意味着服务端可以为每个路由生成独立的html文件。

  • 需要后端支持,否则会出现404问题,因为前端路由会被当作资源路径,发送到后端,而后端并未做对应配置。

  • 对较老版本的浏览器兼容性较差,因为history路由是基于在H5才提出的History API

  • 要求index.html文件引用资源的路径必须使用绝对路径

    • 因为基于History API,我们可以改变URL但是不实现页面跳转,展示的始终是同一个index.html文件。

    • 但是当我们改变路由后(比如从http://localhost:3000变成http://localhost:3000/it/about),再手动刷新页面的时候,就会发送get请求http://localhost:3000/it/about到服务器(假设是开发服务器devServer),

    • 显然对于这个请求url,开发服务器找不到对应的资源,于是返回根目录(通常是public文件)下的index.html文件(歪打正着)

    • 但是其他资源就没有这么好运了,浏览器拿到这个页面进行解析渲染,然后加载页面中的资源,比如css文件,如果我们使用的是相对路径,最终请求这些资源的请求路径,还会与当前页面url拼接,所以当前页面的url是不确定的,而我们资源的位置肯定是固定的,所以很容易找不到对应的资源,所以开发服务器返回index.html文件,你没看错,我们请求css文件结果服务端返回了html文件,然后浏览器就报错了。

  • history路由的项目一般部署在服务器根目录,域名后面的路径就是前端路径,否则需要在前端路由库(比如VueRouter)中做额外配置,确保浏览器能从url中提取出前端路径。

    1
    2
    3
    4
    5
    6
    7
    const router = new VueRouter({
    mode: 'history',
    base: '/app/', // 设置基础路径
    routes: [
    // 你的路由配置
    ]
    });

    例如,如果用户的 URL 是 http://example.com/app/user/profile,那么前端路由库会将 /user/profile 视为实际的路由路径,而 /app/ 则被视为基础路径。

如何做

使用 HTML5 的 History API (history.pushState()history.replaceState()) 来修改 URL,而不会触发页面刷新。

要注意的是,调用这2个api都不会触发popstate事件,只有在用户导航历史栈(通过浏览器的后退或前进按钮)时,才会触发 popstate 事件;而hashchange事件,无论是通过js修改hash,还是点击前进后退按钮修改hash,都会触发hashchange事件

history.pushState(stateObj, title, url)

  • 功能

    • 向浏览器的历史栈中添加一个新的记录,历史栈长度+1,

    • 并更新当前 URL,但不重新加载页面。

  • 参数

    • stateObj: 一个对象,用于存储与该状态相关联的数据,可以通过 popstate 事件的事件对象event访问。

      1
      2
      3
      4
       window.addEventListener('popstate', e => {
      //console.log(e)
      const path = e.state && e.state.path;
      });

      也可以通过history.state属性访问。

    • title:通常被忽略或设为空字符串(大多数浏览器不支持)。

    • url:新的 URL,可以是相对路径或绝对路径,但不能改变域名,否则会报错

history.replaceState(stateObj, title, url)

  • 功能
    • 替换当前的历史记录条目,而不是添加新的条目。
    • 它同样更新当前 URL 但不刷新页面。
  • 参数:与 pushState 相同。

监听 popstate 事件来响应浏览器的前进/后退按钮操作。

最终代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Router {
constructor() {
//存储路由
this.routes = {};
//页面初次加载的时候,修改路径为 '/',并触发对应的事件回调
window.addEventListener('load', () => {
history.replaceState({ path: '/' }, null, '/');
this.routes['/'] && this.routes['/']();
})
// 监听popstate事件,也就是监听浏览器返回/前进按钮点击,然后触发对应的回调函数
window.addEventListener('popstate', e => {
//这里是通过popstate的事件对象获取到了当前页面的状态(栈顶页面,或者说前进,回退操作后的页面)
//其实还是可以通过location获得的吧,就是location.pathname
const path = e.state.path;
this.routes[path] && this.routes[path]();
});
}
//用来注册路由
route(path, callback) {
this.routes[path] = callback;
}
//用来修改路由
push(path) {
history.pushState({ path }, null, path);
//修改之后立马调用对应的回调函数,而不是等待触发popstate事件,因为不会触发
this.routes[path] && this.routes[path]();
}
}

// 使用 Router
window.miniRouter = new Router();
//注册路由
miniRouter.route('/', () => console.log('首页'));
miniRouter.route('/page2', () => console.log('page2'));

// 跳转
miniRouter.push('/page2'); // 输出 'page2'

vue如何做前端性能优化

前端性能优化就包括了“如何提高首屏的加载速度”。

编码优化

  • 使用事件代理:使用事件委托能减少内存占用,减少不必要的重复代码。关于事件委托的介绍,可以参考前端面试—js部分 | 三叶的博客
  • 使用keep-alive缓存组件:会缓存不活动的组件实例,而不是销毁它们,防止重复渲染DOM
  • 使用路由懒加载,本质是按需加载css,js文件
  • 保证key值唯一,有利于diff算法复用dom,虽然key值不唯一也会提示,也不需要我们操心。

减少资源体积

这部分的内容,其实主要是模块化打包工具帮助我们实现的,不需要我们操心。

  • 压缩css,js文件:使用打包工具比如webpack,vite压缩css,js文件(删除注释,空格,合并多个文件)
  • tree-shaking:使用tree-shaking移除未使用的代码,减少最终打包后的文件体积,虽然现在的打包工具都默认支持tree-shaking。
  • 压缩图片体积:使用webp格式替代jpg或者png格式的图片,压缩图片体积。

加载优化

  • 使用图片懒加载,我们可以手动实现图片懒加载指令
  • 缓存图片,css,js文件等静态资源。在构建过程中,为静态资源文件名添加内容哈希值(例如 app.a1b2c3d4.js),这样每次更新文件时都会生成一个新的URL,浏览器会认为这是一个全新的资源而重新下载它,而不是使用缓存,这是也是打包工具会帮忙做的事情。

总结

分析到现在,貌似需要我们控制的性能优化分案,貌似只有事件委托,使用keep-alive,使用路由懒加载 ,使用图片懒加载。

如何解决给对象添加属性视图不刷新的问题

我们从一个例子开始

定义一个p标签,通过v-for指令进行遍历

然后给botton标签绑定点击事件,我们预期点击按钮时,数据新增一个属性,界面也 新增一行

1
2
3
4
<p v-for="(value,key) in item" :key="key">
{{ value }}
</p>
<button @click="addProperty">动态添加新属性</button>

实例化一个vue实例,定义data属性和methods方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const app = new Vue({
el:"#app",
data:()=>{
item:{
oldProperty:"旧属性"
}
},
methods:{
addProperty(){
this.items.newProperty = "新属性" // 为items添加新属性
console.log(this.items) // 输出带有newProperty的items
}
}
})

点击按钮,发现结果不及预期,数据虽然更新了(console打印出了新属性),但页面并没有更新

为什么

为什么产生上面的情况呢?下面来分析一下

vue2是用过Object.defineProperty实现数据响应式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const obj = { val: 0 }
let val = obj.val
Object.defineProperty(obj, 'val', {
get() {
console.log(`get val:${val}`);
return val
},
set(newVal) {
if (newVal !== val) {
console.log(`set val:${newVal}`);
val = newVal
}
}
})
obj.val //get val:0
obj.val = 1 //set val:1

当我们访问val属性或者设置foo值的时候,都能够触发settergetter

但是我们为obj添加新属性的时候,却无法触发事件属性的拦截

1
obj.bar  = '新属性'

这是Object.defineProperty在设计上存在的问题,无法监听到对象属性的添加,删除,只能监听已有属性的getter和setter

如何解决

可以通过**Vue.set()或者this.$set()**来给新增属性添加响应式。

Vue.set( target, key, value )

  • target:可以是一个对象,也可以是一个数组
  • key:可以是一个字符串类型的属性,也可以是一个下标(数字)
  • value:值可以是任意类型

这个方法的本质就是使用Object.defineProperty()来添加一个新的响应式属性,因为直接给对象添加的属性,是不具备响应式的。

但是,我们只能监听已有属性的getter和setter,即便添加了一个响应式属性,也是无法监听到的,所以还需要通知所有依赖这个对象的Watcher(告诉它们,我新增一个属性啦),触发视图更新。

同样的,通过Vue.delete()this.$delete来解决删除对象属性,视图不更新的问题。

v-if和v-for的优先级是什么

在vue2中,v-for的优先级高于v-if,也就是说会遍历所有元素,然后再通过v-if判断是否是要渲染,即使某些项最终不满足 v-if 条件,v-for 仍会遍历这些项。

1
2
3
4
5
6
<ul>
<li v-for="item in items" v-if="item.isVisible" :key="item.id">
{{ item.name }}
</li>
</ul>
//这个例子中,Vue 2 首先遍历 `items` 数组(通过 `v-for`),然后对每个项应用 `v-if` 来决定是否渲染该项。

而在vue3中,v-if的优先级高于v-for,所以在vue3中,上述代码会报错,会提示item未被定义;

这也意味着在vue3中,无法根据某个对象的属性,使用v-if来控制渲染。

其实最推荐的做法是只迭代并渲染需要渲染的数据,不在同一个元素上使用v-ifv-for,这就需要我们提前过滤元素。

v-if和v-show如何理解

共同点

二者都是用来控制页面中元素的显示与隐藏,当表达式值为false的时候,都不会占据页面的位置。

区别

v-show本质是通过切换css样式来实现元素的显示与隐藏,令display:none让元素隐藏,dom元素还存在。

v-if本质则是通过控制dom元素的创建与删除来实现元素的显示与隐藏,因为v-if直接操作dom,所以v-if有更高的性能消耗。

v-if才是真正的条件渲染v-show的值为false的元素,也会被渲染,因为它还是会出现在文档中,只是变得不可见且不占据位置。

说说你对nextTick的理解

在vue中,虽然是数据驱动视图更新,但是数据改变(同步改变),vue异步操作dom来更新视图,而传入nextTick的回调函数,能确保在DOM更新之后再被执行,所以nextTick回调函数中能访问到最新的DOM。

使用方法

Vue.nextTick(()=>{})或者this.$nextTick(()=>{})

1
2
3
4
5
6
7
8
<div id="app"> {{ message }} </div>

const vm = new Vue({
el: '#app',
data: {
message: '原始值'
}
})
1
2
3
4
5
6
//使用回调函数
this.message = '修改后的值'
console.log(this.$el.textContent) //'原始的值'
this.$nextTick(function () {
console.log(this.$el.textContent) //'修改后的值'
})

如果调用nextTick的时候,没有传入回调函数,则会返回一个Promise对象,当这个Promise对象的值改变后,就能访问到最新的DOM

1
2
3
4
5
//使用async/await
this.message = '修改后的值'
console.log(this.$el.textContent) //'原始的值'
await this.$nextTick()//此时没有传入回调函数
console.log(this.$el.textContent) //'修改后的值'

底层实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const callbacks = []  // 存放传入nextTick的回调函数
let pending = false // 控制timerFunc的调用频率
let timerFunc //后续会被定义

export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
//将传入的回调函数,放入callbacks中
//这个过程是同步发生的,但是callbacks中的函数被执行却是发生在微任务阶段
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
//如果是首次调用nextTick,再调用一次timerFunc
//pending = true的意义是如果再调用nextTick,不再调用timerFunc
//这意味着即便多次同步调用nextTick,只会在第一次调用的时候,将清空callback的任务,放入者微任务(或者宏任务)队列
pending = true
timerFunc()//效果是将flushCallbacks放入微任务(或者宏任务)队列
}
// $flow-disable-line
// 如果没传入回调函数,返回一个Promise对象
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}

callbacks新增回调函数后,又执行了timerFunc函数,那么这个timerFunc函数是做什么用的呢,我们继续来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export let isUsingMicroTask = false
if (typeof Promise !== 'undefined' && isNative(Promise)) {
//判断1:是否原生支持Promise
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
//判断2:是否原生支持MutationObserver
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
//判断3:是否原生支持setImmediate
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
//判断4:上面都不行,直接用setTimeout
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}

上述代码描述了timerFunc定义过程,做了四个判断,对当前环境进行不断的降级处理,尝试使用原生的Promise.thenMutationObserversetImmediate,上述三个都不支持最后使用setTimeout

通过四个判断可以确保,无论在何种浏览器条件下,都能定义出最合适timerFunc。而且四种情况下定义的timerFunc效果都是,将flushCallbacks放入微任务(或者宏任务)队列

timerFunc不顾一切的要把flushCallbacks放入微任务或者宏任务中去执行,它究竟是何方神圣呢?让我们来一睹它的真容:

1
2
3
4
5
6
7
8
9
function flushCallbacks () {
//释放pending,确保下次事件循环同步调用nextTick的时候,能触发timerFunc
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}

来以为有多复杂的flushCallbacks,居然不过短短的几行。它所做的事情也非常的简单,把callbacks数组复制一份,然后把callbacks置为空,最后把复制出来的数组中的每个函数依次执行一遍;所以它的作用仅仅是用来执行callbacks中的所有回调函数,也就是说,callbacks中的任务,会在微任务阶段(或者宏任务)被执行。

如何确保此时DOM是最新的?

经过上面的介绍我们知道,传入nextTick的回调函数,通常会在微任务阶段被依次执行,那又是如何确保nextTick中的回调函数访问到的DOM是最新的DOM呢?

就如同nextTick中存在callbacks队列一样,在vue中修改数据,会触发对应的setter,然后将对应的更新操作,push到一个异步更新队列中(不同于callbacks),然后负责清空这个异步更新队列的任务,也会被放入微任务队列中,就如同清空callbacks的任务:flushCallbacks,会被timeFunc放入微任务队列中,不过由于清空这个异步更新队列的任务,先于flushCallbacks被执行,所以nextTick中的回调函数访问到的DOM是最新的DOM。下面用例子说明:

1
2
3
4
5
6
7
8
this.msg = '我是测试文字'
this.$nextTick(()=>{
console.log(1)
})
this.childName = '我是子组件名字'
this.$nextTick(()=>{
console.log(2)
})
  • 同步调用,this.msg = '我是测试文字',触发msg属性的setter,然后会开启一个异步更新队列,将依赖msg的所有Watcher放入异步更新队列,并将清空异步更新队列的任务,放入微任务队列中

  • 同步调用,this.$nextTick( ()=>{ console.log(1) } ),将()=>{ console.log(1) }放入callbacks中,并且将flushCallbacks放入微任务中

  • 同步调用,this.childName = '我是子组件名字',触发childName属性的setter,将依赖childName的所有Watcher放入异步更新队列

  • 同步调用this.$nextTick(()=>{ console.log(2) }),将()=>{ console.log(2) }放入callbacks中。

  • 同步任务执行完毕,开始执行微任务,执行清空异步更新队列的任务,更新DOM

  • 从微任务中取出flushCallbacks执行,清空callbacks队列

  • 为了确保清空异步更新队列的任务,先于flushCallbacks被放入微任务队列,需要先同步执行修改数据的操作

什么是虚拟DOM?有什么作用?如何实现?

在js中的情况

这部分内容主要参考js中的事件循环,可参考本博客内的《javascript》一文

在原生 JavaScript 的事件循环中,多次 DOM 操作会 立即修改内存中的 DOM 树,但浏览器通过 批量更新,合并机制, 延迟视图渲染至事件循环末尾。

1
2
3
4
// 同一事件循环中多次修改同一元素的样式
element.style.width = "100px";
element.style.height = "200px";
element.style.backgroundColor = "red";

浏览器会将这三次样式修改,合并为一次渲染流程,而非逐次触发三次重排,所以不会看到样式闪烁,因为只渲染了一次。

虽然减少了渲染次数,但每次 DOM 操作仍会 立即修改内存中的 DOM 树,频繁操作可能导致主线程阻塞(如复杂布局计算),因为操作DOM是费时的(比如一个DOM对象身上有很多属性,创建一个DOM是费时间的),所以在Vue等框架中,使用虚拟DOM和diff算法,来减少操作真实DOM的次数

虚拟DOM

虚拟DOM(虚拟DOM树)本质就是一个用来描述真实DOM(真实DOM树)的js对象,是对真实DOM(真实DOM树)的高度抽象

1
2
3
<div id="app">
<p class="text">hello world!!!</p>
</div>

将上面的HTML模版抽象成虚拟DOM树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
tag: 'div',
props: {
id: 'app'
},
chidren: [
{
tag: 'p',
props: {
className: 'text'
},
chidren: [
'hello world!!!'
]
}
]
}

操作虚拟 DOM 的速度,比直接操作真实 DOM 快 10-100 倍

VNode

虚拟DOM树本身是一个js对象,是对真实DOM树的高度抽象,而VNode是虚拟DOM树上的结点,是对真实DOM结点的抽象,它描述了应该怎样去创建真实的DOM结点。

创建虚拟DOM

在Vue 通过 createElement 函数(简写为 h,即 “hyperscript”)生成 VNode 树,每个 VNodechildrenchildren 每个元素也是一个VNode,这样就形成了一个虚拟树结构,用于描述真实的DOM树结构。

一个典型的 vnode 对象可能包含以下字段:

  • tag: 元素类型(例如 'div''span' 等)
  • data: 包含元素的属性、样式、事件处理器等元数据
  • children: 子节点数组,可以是其他 vnode 或文本字符串
  • text: 如果是文本节点,则包含文本内容
  • el: 引用对应的真实DOM节点(仅在某些实现中存在)

举例说明:

1
2
3
4
<div class="container" style="color: red;">
Hello Vue!
<span>子节点</span>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 使用 Vue 的 render 函数和 createElement
const vnode = createElement(
"div", // tag
{
class: "container",
style: { color: "red" },
onClick: () => console.log("Div clicked"), // 事件处理器
}, // data
[
"Hello Vue! ", // 文本节点
createElement("span", null, "子节点"), // 子 VNode
]
);

// 等价的简化写法(Vue 2.5+ 使用 h 函数):
const vnode = h(
"div",
{
class: "container",
style: { color: "red" },
onClick: () => console.log("Div clicked"),
},
["Hello Vue! ", h("span", null, "子节点")]
);

生成的 VNode 结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
tag: "div",
data: {
class: "container",
style: { color: "red" },
on: { click: () => console.log("Div clicked") },
},
children: [
"Hello Vue! ", // 文本节点(类型为字符串)
{
tag: "span",
data: null,
children: ["子节点"],
text: undefined,
el: undefined,
},
],
text: undefined,
el: undefined,
};

在Vue中的情况

  • 在vue中,虽然是数据驱动视图更新的,当数据被修改时,会触发对应的 setter,但不会立即修改dom,而是通知依赖更新,调用所有依赖(Watcher)的update方法:将Watcher自身放入异步更新队列中
  • 然后在微任务阶段,清空异步更新队列:
    • 调用每个Watcher的run方法,要注意的是,虽然每个Key都可以有多个Watcher,但并不是所有Watcher都是渲染Watcher(负责组件的视图更新,每个组件对应一个渲染 Watcher),只有渲染 Watcher 的 run 方法触发 render,生成新虚拟 DOM → Diff → DOM 更新。
    • 如果完全按照新的虚拟dom树,来创建新的dom树,就会有许多不必要的dom操作,所以我们会使用diff算法,进行新旧虚拟DOM树的比较,得出最小的变更,应用到对真实dom树的修改。
  • 综上所述,在vue中对真实DOM的修改,是在微任务阶段发生的,然后就到了事件循环的末尾,因为对DOM进行了修改,所以会进行一次渲染。

说说diff算法

具体复用方式

  • 在大多数情况下,一个 HTML 标签对应一个 DOM(Document Object Model)元素。DOM元素本身也是一个js对象,不过身上的属性要多得多。

    1
    <div id="example">Hello, World!</div>

    这个 <div> 标签会被解析为一个 HTMLElement 对象,并且可以通过 JavaScript 访问它,例如使用 document.getElementById('example')

  • 如果父元素的虚拟 DOM 发生了变化,但其子元素的虚拟 DOM 没有变化,在大多数情况下,框架会尝试复用子元素的真实 DOM

  • 当父元素保持不变,而其子元素发生了变化时,框架会对子树执行 diff 操作来确定哪些部分需要更新。

    1
    2
    3
    <div class="parent">
    <div class="child">Initial Message</div>
    </div>

    当我们修改子元素的内部文本:

    1
    2
    3
    <div class="parent">
    <div class="child">Updated Message</div>
    </div>

    父元素 <div class="parent"> 及其属性没有变化,子元素<div class="child"> 的文本内容从 'Initial Message' 变为 'Updated Message'。由于父组件的虚拟 DOM 没有变化,父元素 <div class="parent"> 不会被重新创建或替换,而是继续使用现有的真实 DOM 元素。

  • 简单的来说,新旧虚拟dom的比较是结点级别的,只要某个结点的新旧虚拟dom未改变,就会复用这个dom结点

说说你对vue中key的理解

key是给每一个虚拟dom(或者说vnode)的唯一id。在diff过程中,根据key值,可以更准确, 更快的找到待比较的虚拟dom,从而优化diff算法,提高dom的复用率。

如果不设置key,那key值默认就都是undefined,将会按顺序进行新旧虚拟dom的比较。

详细可参考禹神的vue视频:030_尚硅谷Vue技术_key作用与原理_哔哩哔哩_bilibili

说说你对keep-alive的理解

keep-alive是vue中的内置组件,包裹动态组件(router-view)时,会缓存不活动的组件实例,而不是销毁它们,防止重复渲染DOM

被缓存的组件会额外多出两个生命周期activateddeactivated

keep-alive可以使用一些属性,来更精细的控制组件缓存。

  • include - 字符串或正则表达式或者一个数组。只有名称匹配的组件被缓存
  • exclude - 字符串或正则表达式或者一个数组。任何名称匹配的组件都不会被缓存
  • max - 数字:最多可以缓存多少个组件实例,超出这个数字之后,则删除第一个被缓存的组件,由此可以推测存在一个缓存队列,先入先出。
1
2
3
4
5
6
7
8
9
10
11
12
13
<keep-alive include="a,b">
<component :is="view"></component>
</keep-alive>

<!-- 正则表达式 (使用 `v-bind`,动态绑定,表示传入的是正则表达式,而不是字符串) -->
<keep-alive :include="/a|b/">
<component :is="view"></component>
</keep-alive>

<!-- 数组 (使用 `v-bind,动态绑定,表示传入的是表达式`) -->
<keep-alive :include="['a', 'b']">
<component :is="view"></component>
</keep-alive>

组件名称匹配,组件名称指的到底是什么呢?

匹配首先检查组件自身的 name 选项,如果 name 选项不可用,则匹配它的局部注册名称 (父组件 components 选项的键值),匿名组件不能被匹配。

组件被缓存了,如何获取数据呢?

借助beforeRouteEnter这个组件内的导航守卫,或者activated生命周期函数

1
2
3
4
5
6
7
beforeRouteEnter(to, from, next){
next(vm=>{
console.log(vm)
// 每次进入路由执行
vm.getData() // 获取数据
})
},
1
2
3
activated(){
this.getData() // 获取数据
},

面试官:说说你对keep-alive的理解是什么? | web前端面试 - 面试官系列这篇文章中还讲解了keep-alive的实现原理,看起来还是挺复杂的

vue3中的keep-alive的语法不同于vue2

基础用法,默认缓存所有页面:

1
2
3
4
5
6
<router-view v-slot="{ Component }">//Component可以理解为用来替代router-view的组件,或者说当前活跃的组件
//keep-alive包裹的不再是router-view而是具体的组件
<keep-alive>
<component :is="Component" />//会缓存传入的组件
</keep-alive>
</router-view>

精确控制具体哪些组件缓存,因为再vue3中使用组件已经不再需要注册,也不需要给组件命名,所以我们控制组件(页面)缓存的依据变成了页面的路由对象,而不是组件的名称。同时,我们不再通过给keep-alive标签添加属性来控制哪些组件该被缓存,缓存多少组件,转变为借助v-if,如果某个组件因该被缓存,那么他就会被keep-alive标签包裹。

1
2
3
4
5
6
7
8
<template>
<router-view v-slot="{ Component }">//Component是当前活跃的组件,或者说当前展示的组件
<keep-alive>
<component :is="Component" v-if="$route.meta.keepAlive"/>//获取当前组件对应的路由信息
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive"></component>
</router-view>
</template>

在路由对象中添加meta属性

1
2
3
4
5
6
7
8
{
path: "/keepAliveTest",
name: "keepAliveTest",
meta: {
keepAlive: true //设置页面是否需要使用缓存
},
component: () => import("@/views/keepAliveTest/index.vue")
},

或者

1
2
3
4
5
6
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" v-if="route.meta.keepAlive"></component>
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive"></component>
</router-view>

但是就到此位置的话,切换页面的时候会报错:vue3 TypeError: parentComponent.ctx.deactivate is not a function 报错

网上提供的解决方案就是给每个component提供一个key。

1
2
3
4
5
6
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" :key="route.name" v-if="route.meta.isKeepAlive"></component>
</keep-alive>
<component :is="Component" :key="route.name" v-if="!route.meta.isKeepAlive"></component>
</router-view>

详细可参考:vue3中使用keep-alive目的:掘金

说说vue中的Mixin

mixin本质就是一个js对象,包含了vue组件任意功能选项,如datacomponentsmethodscreatedcomputed等等

,被用来分发 Vue 组件中的可复用功能

可分为全局混入和局部混入

1
2
3
4
5
Vue.mixin({
created: function () {
console.log("全局混入")
}
})//全局混入
1
2
3
export default {
mixins:[{created:()=>{}}]
}

如果混入组件的时候出现了功能选项冲突,一般以组件功能选项为准。

面试官:说说你对vue的mixin的理解,有什么应用场景? | web前端面试 - 面试官系列

vue3的组合式api中,混入(mixin)显然就没有用武之地了,转而被composable替代,下面就是一个例子,介绍了在vue3中是如何复用代码的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//useCountDown.js
import { ref, computed } from 'vue'
import dayjs from 'dayjs'
import { useRouter } from 'vue-router'
//导出一个函数
export const countDown = () => {
const router = useRouter()
const Time = ref(0)
const formatTime = computed(() => dayjs.unix(Time.value).format('mm分ss秒'))
const start = (time) => {
Time.value = time
let n = setInterval(() => {
Time.value--
if (Time.value == 0) {
clearInterval(n)
ElMessage.error('订单超时')
router.push('/cartList')
}
}, 1000)
}
return { formatTime, start }
}

非常好理解啊,就像大多数编程语言一样,把能实现部分功能的代码封装成一个函数,需要的时候再导入这个函数,调用这个函数,和把这些代码直接写在组件中相比,区别只于私有化了变量,需要通过return导出。

跨域是什么?Vue项目中你是如何解决跨域的呢?

是什么

跨域本质是浏览器基于同源策略的一种安全手段,它是浏览器最核心也最基本的安全功能,服务器间通信不会有跨域的问题。

所谓同源(即指在同一个域)具有以下三个相同点

  • 协议相同(protocol)
  • 主机相同(host)
  • 端口相同(port)

反之非同源请求,也就是协议、端口、主机其中一项不相同的时候,这时候就会产生跨域(非同源产生跨域)

举个例子,我们直接打开 HTML 文件使用的是file:///协议加载,如果文档内部请求了其他网络资源,因为HTTP 请求使用的是 http://https:// 协议,协议不同,就发生了跨域。

跨站有什么区别呢?跨站不涉及协议和端口号,一般情况下,跨站指的就是主域名不同,比如www.bilibili.comgame.bilibili.com属于同站。

如何解决

  • JSONP
  • CORS
  • Proxy

JSONP

  • 利用了script标签可以跨域加载脚本

  • 动态创建一个script标签,并自定它的src属性为目标服务器的url

  • 这个url通常包含一个查询参数,用于指定客户端上的回调函数名

  • 服务端接收到请求后,返回包含函数调用的js代码,其中传入函数的参数,就是服务器传递的参数。

  • 但jsonp请求有个明显的缺点:只能发送get请求

1
2
3
4
5
6
7
8
9
10
11
 function onClick(){
const script = document.createElement('script')
script.src = "http://127.0.0.1:8081/api/callback?callback=hello"
//给script标签对象添加监听事件
document.body.appendChild(script)
//比addEventListener写法简单
//原始事件监听模型
script.onload = () =>{
script.remove()//调用remove方法删除这个标签
}//脚本加载后立马删除,监听*onload*事件
}
1
<button onclick="onClick()">+</button>

其实还有其他标签可以跨域加载资源,貌似大部分标签都可以跨域加载资源…

媒体资源

标签作用
img标签可以跨域加载图像资源,但是如果给img标签加上crossorigin属性,那么就会以跨域的方式请求图片资源
audio和video标签可以跨域加载视频,音频

前端基础三大文件

标签作用
link标签可以跨域加载CSS文件
iframe标签可以跨域加载HTML页面。
script标签可以跨域加载脚本

crossorigin属性

虽然上述三大标签默认可以跨域加载资源,但是如果添加了crossorigin属性,情况就不同了,此时加载资源同样受同源策略限制,请求这这些资源的时候,会携带Origin头,并且要求响应头中包含Access-Control-Allow-Origin字段。

尽管 <script> 默认允许跨域加载,但 crossorigin 属性的核心意义在于:

  1. 调试需求:前端可以获取跨域脚本的详细错误日志(开发阶段尤其关键)

  2. 安全增强:强制验证服务器是否明确允许当前来源(避免滥用第三方资源)。

  3. 特殊资源要求,例如:

    • 字体文件:通过 <link> 加载的跨域字体必须使用 crossorigin

    • ES6 模块<script type="module"> 加载的模块必须启用 CORS,所以说vue3项目打包后,引入js文件的方式如下:

      1
      <script type="module" crossorigin src="/assets/index-RPTkaswq.js"></script>

      默认添加了crossorigin头。

行为不加 crossorigincrossorigin
是否允许跨域加载✅ 允许✅ 允许(需服务器支持 CORS)
是否验证 CORS 头❌ 不验证✅ 必须验证
错误信息详情❌ 仅 Script error.跨域脚本可能包含敏感逻辑或数据,因此浏览器不会将详细的错误信息暴露给非同源页面✅ 完整错误信息(需 CORS 允许)
适用场景不关心错误细节的公共库需调试或加载字体/模块等特殊资源

Proxy

代理(Proxy)也称网络代理,是一种特殊的网络服务,允许一个(一般为客户端)通过代理与另一个网络终端(一般为服务器)进行非直接的连接。一些网关、路由器等网络设备具备网络代理功能。一般认为代理服务有利于保障网络终端的隐私或安全,防止攻击。

代理的方式也可以有多种:

  • 在脚手架中配置

    在开发过程中,我们可以在脚手架中配置代理。我们可以通过webpack(或者vite)为我们开起一个本地服务器(devServer,域名一般是localhost:8080),作为请求的代理服务器,所以说,这个本地服务器不仅能部署我们开发打包的资源,还能起到代理作用。

    通过该服务器转发请求至目标服务器,本地代理服务器得到结果再转发给前端,因为服务器之间通信不存在跨域问题,所以能解决跨域问题。

    打包之后的项目文件,因为脱离了代理服务器,所以说这种方式只能在开发环境使用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //vue.config.js 即vue-cli脚手架(基于webpack)开发的vue项目
    devServer: {
    //感觉这些信息都是在告诉代理服务器该怎么做
    proxy: {
    '/api': {//匹配所有以/api开头的请求路径
    target: 'http://localhost:3000', // 告诉代理服务器要请求的目标服务器地址
    changeOrigin: true, //改变代理服务器请求目标服务器时的host,代理服务器修改host为目标服务器的域名+端口
    pathRewrite: { '^/api': '' }, // 告诉代理服务器,重写路径,移除前缀
    }
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    //vite.config.js 即vue-create脚手架(基于vite)开发的vue项目
    server: {
    proxy: {
    '/api': {//匹配所有以/api开头的请求路径
    target: 'http://localhost:3000', // 目标服务器地址
    changeOrigin: true, //改变代理服务器请求目标服务器时的host,代理服务器修改host为目标服务器的域名
    rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,移除前缀
    }
    }

    可以看到,我们要使用代理,在编写接口时,就不能书写完整的路径,比如就不能直接把请求url写成https://www.sanye.blog/books,这样必然跨域,我们应该把请求写为/books,部署到本地服务器后加载网页,发起这个请求前,会先自动与域名拼接,实际的请求就变为http://localhost:8080/books,这样就没跨域,不过确实,这么操作的话,就是在请求本地服务器中的books资源,而不是目标服务器中的,如果我们本地服务器中有这个资源(vue-cli中是public目录下有books文件,无后缀),那么本地服务器就会把这个资源返回给浏览器,无论我们是否开启了代理,所以我们实际还要添加/api类似的多余的前缀,确保我们访问的不是本地服务器中的资源,然后本地服务器会帮我们按照配置的规则进行路径重写,得到正确的请求URL,再向目标服务器请求资源。

  • 在服务端开启代理

    其实也不是打包后就不能通过代理来解决跨域问题,如果我们把打包后的前端资源部署到本地的服务器,比如使用基于node.jsexpress框架搭建的本地服务器,我们也可以通过配置代理来解决跨域问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const express = require( 'express ')
    const app = express()
    //其实webpack-dev-server开启代理功能的核心也是这个中间件
    const { createProxyMiddleware } = require( 'http-proxy-middleware ');
    app.use(express.static( . /public))//引入静态资源
    app.use( '/api' ,createProxyMiddleware({
    target: ' https:// www.toutiao.com',
    changeOrigin:true,
    pathRewrite:{
    '^/api ' : ''
    }
    }))

    总之想要配置代理,就离不开一台允许你配置代理的服务器,把打包后的前端资源托管到其他平台,我们也无法来配置代理,也就无法解决跨域问题。

CORS

CORS (Cross-Origin Resource Sharing),即跨域资源共享,意思就是虽然你在跨域请求我的资源,但是我还是选择性的共享资源给你,浏览器根据响应头中的特定字段,来决定是否拦截跨域请求返回的数据。

因为需要在响应头上做文章,所以这个工作主要是前后端协调后,由后端负责,至于前后端如何协调,参考简单请求复杂请求部分。

如何理解简单请求和复杂请求

区别二者的关键,就在于请求方法请求头,简单请求是在请求方法和请求头上,都有严格要求的请求,违背任何一条要求,都将变为复杂请求。

简单请求复杂请求
请求方法(携带在请求行中)get,post,head除get,post,head外的请求方法
请求头满足cors安全规范(一般不修改请求头就是安全的)Content-Type 的值仅限于以下三种之一: application/x-www-form-urlencoded multipart/form-data text/plain,且未自定义其他请求头设置了自定义的请求头,或者 Content-Type 的值不是上述三种之一

在非跨域情况下,区分二者并没有什么意义,但是在跨域情况下,发送复杂请求前,会先发送一次预检请求,请求方法为options

在请求头中携带OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段,询问服务器是否接受来自xxx源,请求方法为xxx,请求头为xxx的跨域复杂请求,如果接受,才发送这样的复杂请求

服务端处理代码(以express框架为例)S

1
2
3
4
5
6
7
app.options( '/students ',( req,res)=>{
res.setHeader('Access-Control-Allow-Origin' , 'http://127.0.0.1:5500')
res.setHeader('Access-Control-Allow-Methods ' , 'GET')
res.setHeader('Access-Control-Allow-Headers ' ,'school'
res.setHeader('Access-Control-Max-Age ' , 7200)//告诉浏览器在7200s内不要再发送预检请求询问
res.send()
})

这样处理起来明显比较繁琐,实际上我们借助CORS中间件就能统一处理简单请求和复杂请求(包括预检请求)的跨域问题。

head请求

HTTP请求方法 HEAD ,是一种用于请求资源元信息的请求方法,它与 GET 请求类似,但有一个关键的区别:服务器在响应中不会返回消息体(即实际的内容),只返回头部信息(Headers)。这意味着当你发送一个 HEAD 请求时,你只会收到关于该资源的元数据,例如内容类型、大小、最后修改时间等,而不会收到文档的实际内容。

使用场景

  • 检查资源的状态:可以用来检查资源是否存在、获取资源的最新修改时间或其他头部信息,而不必下载整个资源。
  • 测试链接的有效性:在不加载整个页面或资源的情况下,验证URL的有效性和可访问性。
  • 性能优化:在需要了解文件大小以准备接收之前,可以通过 HEAD 请求先获取文件的大小信息。这在处理大文件下载前特别有用,因为它允许客户端决定是否继续下载。
  • 缓存验证:可以用来检查本地缓存的副本是否仍然有效,通过比较缓存中的头部信息和服务器返回的头部信息。

vue项目如何部署?有遇到布署服务器后刷新404问题吗

如何部署

前后端分离开发模式下,前后端是独立布署的,前端只需要将最后的构建物上传至目标服务器的web容器指定的静态目录下即可,我们知道vue项目在构建打包后,是生成一系列的静态文件。

404问题

HTTP 404 错误意味着链接指向的资源不存在,问题在于为什么不存在?且为什么只有history模式下会出现这个问题,而hash模式下不会有?

history模式,刷新页面,前端路由部分会被当作请求URL的一部分发送给服务器,然而服务器并没有相关配置,所以响应404

而hash模式,前端路由在URL的#后面,不会被当作请求URL的一部分。

要解决使用history路由的项目,刷新页面出现的404问题,必须和后端沟通,当请求的页面不存在时,返回index.html,把页面控制权全交给前端路由

但是这样有个问题,就是后端服务器不会再响应404错误了,当找不到请求的资源总是会返回index.html,即便请求的资源在前后端中都不存在(即把页面控制权交给前端路由,也没有对应的页面),所以为了避免这种情况,应该在 Vue应用里面覆盖所有的路由情况,最后给出一个 404 页面(虽然说是404页面,但是响应状态码是200,因为返回了index.html)

直接打开页面空白问题

直接打开页面,页面空白本质就是因为js文件加载失败

因为我们开发的是单页面应用程序,需要借助js操作dom来更新页面,而本身的html文件中并没有任何结构,所以如果js文件加载失败,页面就不会有任何结构,所以显示空白。

那为什么js文件会加载失败呢,原因分为两种,一种是加载js文件的路径错误,这通常出现在使用绝对资源路径的情况(使用history路由),为了得到最终的路径还会和盘符(C:或者D:)拼接,所以找不到资源。

还有一种是请求资源的时候跨域了,为什么会跨域了,我们加载的不是本地的js文件文件吗?确实,加载本地资源出现跨域,导致资源加载失败的问题,只会出现在vue3项目中,而vue2项目中不会有这个问题,为什么呢?vue3默认使用vite构建工具,打包后会生成基于esm的代码,浏览器在file://协议下加载esm时,会触更严格的跨域安全策略,导致本地的css,js文件也被视为跨域资源,所以资源加载失败

1
2
<!--可以观察到这个模块的type='module',这意味着这个js文件内使用了esm语法(比如import),这个js文件成为了esm-->
<script type="module" crossorigin src="/assets/index-RPTkaswq.js"></script>

而vue2项目通常使用webpack打包,生成的代码通常以传统脚本的形式加载,此时浏览器对file://协议的跨域闲置比较宽松。

说下你的vue项目的目录结构,如果是大型项目你该怎么划分结构和划分组件呢 ?

在划分项目结构的时候,需要遵循一些基本的原则:

  • 文件夹和文件夹内部文件的语义一致性
  • 单一入口/出口
  • 就近原则,紧耦合的文件应该放到一起,且应以相对路径引用
  • 公共的文件应该以绝对路径的方式从根目录引用
  • /src 外的文件不应该被引入

文件夹和文件夹内部文件的语义一致性

我们的目录结构都会有一个文件夹是按照路由模块来划分的,如pages文件夹,这个文件夹里面应该包含我们项目所有的路由模块,并且应该包含路由模块,而不应该有别的其他的非路由模块的文件夹

这样做的好处在于一眼就从 pages文件夹看出这个项目的路由有哪些

单一入口/出口

举个例子,在pages文件夹里面存在一个seller文件夹,这时候seller 文件夹应该作为一个独立的模块由外部引入,并且 seller/index.js 应该作为外部引入 seller 模块的唯一入口

1
2
3
4
5
// 错误用法
import sellerReducer from 'src/pages/seller/reducer'//可以是reducer,就可以是其他,这样出口就不唯一。

// 正确用法
import { reducer as sellerReducer } from 'src/pages/seller'//默认引入seller目录下的index.js文件

就近原则,紧耦合的文件应该放到一起,且应以相对路径引用

使用相对路径可以保证模块内部的独立性

1
2
3
4
// 正确用法
import styles from './index.module.scss'
// 错误用法
import styles from 'src/pages/seller/index.module.scss'

举个例子

假设我们现在的 seller 目录是在 src/pages/seller,如果我们后续发生了路由变更,需要加一个层级,变成 src/pages/user/seller

如果我们采用第一种相对路径的方式,那就可以直接将整个文件夹拖过去就好,seller 文件夹内部不需要做任何变更。

但是如果我们采用第二种绝对路径的方式,移动文件夹的同时,还需要对每个 import 的路径做修改

总之就是要体会到相对路径的好处,移动文件也不需要修改路径,只要相对位置没变就好。

/src 外的文件不应该被引入

vue-cli脚手架已经帮我们做了相关的约束了,正常我们的前端项目都会有个src文件夹,里面放着所有的项目需要的资源,js, css, png, svg 等等。src 外会放一些项目配置,依赖,环境等文件

这样的好处是方便划分项目代码文件和配置文件

Vue如何做权限管理

权限是对特定资源的访问许可,所谓权限控制,也就是确保用户只能访问到被分配的资源

而前端权限归根结底是请求的发起权,请求的发起可能有下面两种形式触发

  • 页面加载触发
  • 页面上的按钮点击触发

如何做

前端权限控制可以分为四个方面:

  • 接口权限
  • 按钮权限
  • 菜单权限
  • 路由权限

接口权限

接口权限目前一般采用jwt的形式来验证,没有通过的话一般返回401(用户不存在),跳转到登录页面重新进行登录

登录完拿到token,将token存起来,通过axios请求拦截器进行拦截,每次请求的时候头部携带token

1
2
3
4
5
6
7
8
9
axios.interceptors.request.use(config => {
config.headers['token'] = cookie.get('token')
return config
})
axios.interceptors.response.use(res=>{},{response}=>{
if (response.data.code === 40099 || response.data.code === 40098) { //token过期或者错误
router.push('/login')//路由跳转
}
})

Vue3

Vue2与Vue3有什么不同

  • 响应式实现方法不同:vue2的响应式是基于Object.defineProperty实现的,而在vue3中,响应式是基于Proxy实现的

  • api风格不同:在vue2使用的是选项式api,在vue3中既可以使用组合式api,又可以使用选项式api。

  • 组件注册方式不同:Vue3导入组件后,也不需要在components里注册

  • 模板语法不同:Vue2中模板里只能有一个根标签,而在Vue3里可以有多个,因为这些根标签都会被fragment标签包裹

Vue3做了哪些优化?

这是一个很大的话题,这里只做简要介绍,后续对每个部分都有详细解释

  • 更小

    Vue2本身不支持tree-shaking,Vue 2 的核心库和其插件或附加功能(如路由管理、状态管理等)通常是作为一个整体提供的,而不是作为独立可选模块。这意味着即使你的应用只使用了 Vue 的一小部分功能,整个 Vue 库也会被打包进去。

    Vue3移除了一些不常用的 API,引入tree-shaking,可以将无用模块“剪辑”,仅打包需要的,使打包的整体体积变小了

  • 更快

    主要体现在编译方面:diff算法优化,静态提升,事件监听缓存,SSR优化

  • 更友好

    vue3在兼顾vue2options API的同时,还推出了composition API,大大增加了代码的逻辑组织和代码复用能力。

源码

源码可以从两个层面展开:

  • 源码管理

    vue3整个源码是通过 monorepo的方式维护的,根据功能将不同的模块拆分到packages目录下面不同的子目录中

    这样使得模块拆分更细化,职责划分更明确,模块之间的依赖关系也更加明确,开发人员也更容易阅读、理解和更改所有模块源码,提高代码的可维护性。

  • TypeScript

    Vue3是基于typeScript编写的,提供了更好的类型检查,能支持复杂的类型推断 。

性能

  • 体积优化(支持tree-shaking),编译优化

  • 数据劫持优化:在vue2中,数据劫持是通过Object.defineProperty,这个 API 有一些缺陷,并不能检测对象属性的添加和删除

    1
    2
    3
    4
    5
    6
    7
    8
    Object.defineProperty(data, 'a',{
    get(){
    // track
    },
    set(){
    // trigger
    }
    })

    尽管Vue为了解决这个问题提供了 setdelete实例方法,但是对于用户来,还是增加了一定的心智负担。

    相比之下,vue3是通过proxy监听整个对象,而不是监听属性,那么无论是删除对象属性,还是给对象添加属性,当然也能监听到。同时Proxy 并不能监听到内部深层次的对象变化,而 Vue3 的处理方式是在getter 中去递归响应式,这样的好处是真正访问到的内部对象才会变成响应式,而不是无脑递归添加响应式。

语法API

这里当然说的就是composition API,其两大显著的优化:

  • 优化逻辑组织
  • 优化逻辑复用

vue2中,我们是通过mixin实现功能复用,如果多个mixin混合,会存在两个非常明显的问题:命名冲突数据来源不清晰

比如有2个提供了若干组件功能的js文件,它们在同一个组件内被混入,但是它们都提供了一个名为useMouse的方法,这个时候就产生了冲突,不知道以哪个函数为主;当在一个组件内混入了多个文件的时候,我们只能知道这个组件内混入了哪些文件,不知道具体某个功能是哪个js文件提供的。

而在组合式api中,我们可以将一些可复用的代码,抽离出来作为一个函数并导出,在需要使用的地方导入后直接调用即可。这个种模块化的方式既解决了命名冲突的问题,也解决了数据来源不清晰的问题。为什么呢?因为每个函数的命名一般都是从它的功能出发的。我们导入不同功能的函数,通常不会有命名冲突的问题;而且每个函数的功能明确,需要通过调用函数的方式拿到函数内部返回的数据,这样数据的依赖就很明确了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// mouse/index.js
import { toRefs, reactive, onUnmounted, onMounted } from 'vue';
export default function useMouse(){
const state = reactive({x:0,y:0});
const update = e=>{
state.x = e.pageX;
state.y = e.pageY;
}
onMounted(()=>{
window.addEventListener('mousemove',update);
})
onUnmounted(()=>{
window.removeEventListener('mousemove',update);
})
return toRefs(state);
}

在组件中使用

1
2
3
4
5
6
7
8
//导入
import useMousePosition from './mouse'
export default {
setup() {
const { x, y } = useMousePosition()//直接调用
return { x, y }
}
}

Vue3.0里为什么要用 Proxy API 替代 defineProperty API ?

defineProperty

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改对象的现有属性,并返回此对象。

1
2
3
4
5
6
7
8
9
10
//传入一个对象,将这个对象转变成响应式对象
function observe(obj) {
if (typeof obj !== 'object' || obj == null) {
return
}
//使用Object.keys可比使用for in 然后再使用hasOwnProperty判断方便多了
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function defineReactive(obj, key, val) {
//如果存在嵌套对象的情况,则递归添加响应式。
if( typeof val == 'object'){
observe(val)
}
Object.defineProperty(obj, key, {
get() {
console.log(`get ${key}:${val}`);
return val
},
set(newVal) {
if (newVal !== val) {
//当给key赋值为对象的时候,还需要在set方法中给这个对象也添加响应式。
if(typeof newVal == 'object'){
observe(newVal)
}
val = newVal
//调用update方法,做一些更新视图的工作,依赖这个属性的视图,计算属性,watch都会更新或执行一些逻辑
update()
}
}
})
}
1
2
3
4
5
const arrData = [1,2,3,4,5];
observe(arrData)
arrData.push() //无响应
arrData.pop() //无响应
arrDate[0] = 99 //ok,有响应

缺点小结

  • Object.defineProperty无法监听到数组方法对数组元素的修改
  • 需要遍历对象每个属性逐个添加监听,而且无法监听到对象属性添加删除,如果属性值是嵌套对象,还深层监听,造成性能问题。

Proxy

Proxy的监听是整个对象,那么对这个对象的所有操作会进入监听操作,这就完全可以代理所有属性了

定义一个响应式方法reactive,这个reactive方法就是vue3中的reactive方法的简化版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function reactive(obj) {
if (typeof obj !== 'object' || obj == null) {
return obj
}
// Proxy相当于在对象外层加拦截
const observed = new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
console.log(`获取${key}:${res}`)
return res
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver)
console.log(`设置${key}:${value}`)
return res
},
deleteProperty(target, key) {
const res = Reflect.deleteProperty(target, key)
console.log(`删除${key}:${res}`)
return res
}
})
return observed
}

测试一下简单数据的操作,发现都能劫持

1
2
3
4
5
6
7
8
9
10
11
const state = reactive({
foo: 'foo'
})
// 1.获取
state.foo //输出 "获取foo:foo"
// 2.设置已存在属性
state.foo = 'fooooooo' //输出 "设置foo:fooooooo"
// 3.设置不存在属性
state.dong = 'dong' // 输出 "设置dong:dong"
// 4.删除属性
delete state.dong // 输出 "删除dong:true"

再测试嵌套对象情况,这时候发现就不那么 OK 了

1
2
3
4
5
6
7
const state = reactive({
bar: { a: 1 }
})

//输出"获取bar:[object Object]",就是获取到了{a:1}啦,其实这个操作进行了两次属性访问,但是只触发了一次getter
//这就意味着state.bar返回的对象不具备响应式。
state.bar.a = 10

如果要解决,需要在get之上再进行一层代理

1
2
3
4
5
6
7
8
const observed = new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
console.log(`获取${key}:${res}`)
//如果返回的对象是一个object,则给这个对象添加响应式
return typeof res==='object' ? reactive(res) : res
}
})

修改后输出的结果:

1
2
获取bar:[object Object]
设置a:10

总结

Object.defineProperty这个方法存在许多缺点,比如必须遍历对象的所有属性逐个添加监听,而且无法监听对象属性的增加与删除,如果属性的值是引用类型还需要深度监听,造成性能问题

对于数组,Object.defineProperty方法无法监听到数组方法,对数组元素的修改,需要重写数组方法。

而Proxy能监听整个对象的变化,也能监听到数组方法对数组元素的修改。

说说Vue 3.0中Treeshaking特性?

是什么

Tree shaking 是一种通过清除多余js代码方式来优化项目打包体积的技术。

如何做

Tree shaking是基于ES6模块语法(importexports),主要是借助ES6模块的静态编译思想,在编译时就能确定模块的依赖关系,以及输入和输出的变量。

Tree shaking无非就是做了两件事:

  • 编译阶段利用ES6 Module判断哪些模块已经加载
  • 判断那些函数和变量未被使用或者引用,进而删除对应代码

那么为什么使用 CommonJs、AMD 等模块化方案无法支持 Tree Shaking 呢?

因为在 CommonJs、AMD、CMD 等旧版本的 js 模块化方案中,导入导出行为是高度动态,难以预测的,只能在代码运行的时候确定模块的依赖关系,例如:

1
2
3
4
if(process.env.NODE_ENV === 'development'){
require('./bar');
exports.foo = 'foo';
}

而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句只能出现在模块顶层,可以理解为全局作用域;且导入导出的模块名必须为字符串常量,这意味着下述代码在 ESM 方案下是非法的:

1
2
3
4
if(process.env.NODE_ENV === 'development'){
import bar from 'bar';
export const foo = 'foo';
}

所以,ESM 下模块之间的依赖关系是高度确定的,与运行状态无关,编译工具只需要对 ESM 模块做静态语法分析,就可以从代码字面量中推断出哪些模块值未曾被其它模块使用,这是实现 Tree Shaking 技术的必要条件。

关于tree-shaking更多内容参考:前端面试—webpack | 三叶的博客

关于cjs和esm的更多内容参考:nodejs | 三叶的博客

Composition Api 与 Options Api 有什么不同?

  • 代码组织方式:选项式api按照代码的类型来组织代码;而组合式api按照代码的逻辑来组织代码,逻辑紧密关联的代码会被放到一起。

  • 代码复用方式:在选项式api这,我们使用mixin来实现代码复用,使用单个mixin似乎问题不大,但是当我们一个组件混入大量不同的 mixins 的时候,就存在两个非常明显的问题:命名冲突数据来源不清晰

    而在组合式api中,我们可以将一些可复用的代码抽离出来作为一个函数并导出,在需要在使用的地方导入后直接调用即可。这个种模块化的方式,既解决了命名冲突的问题,也解决了数据来源不清晰的问题。更多内容参考前文《Vue3做了哪些优化?》