Vue2

说说你对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)的前端框架。关键词:

  • js框架
  • 单页面应用程序

Vue核心特性

  • 数据驱动视图更新

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

    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代码。

引入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({})简化而来的,组件注册的时候会自动处理:如果发现注册的组件是一个对象,而不是一个VueComponet构造函数 ,则使用Vue.extend包裹,否则直接注册。

  • 组件之间通过嵌套确定层级关系,所有其他组件都在根组件App.vue内,根组件直接嵌入index.html文件,这一嵌入操作是在main.js中实现的

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

render

你们有没有思考过这个问题,render函数是如何得到的,调用render函数到底做了什么?

引言

createElement常常出现在渲染函数render中,下面举例子说明

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//js对象
},
methods:{
add(){
this.age++
}
}
}
</script>

上述组件模板template,最终会被编译为如下渲染函数render(在$mount方法中,在后文有介绍):

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++')//'age++'是标签体内容
]
);
}

在创建组件根实例的时候,使用的render函数中,也用到了createElement函数,不过这个render函数相当于是用户自定义的,而不是模板解析后得到的,结构非常简单。

1
2
3
4
5
6
7
8
9
10
import Vue from "vue";
import App from "./App.vue";

Vue.config.productionTip = false;

const app = new Vue({
render: (h) => h(App),
}).$mount("#app");

console.log(app);

import一个App组件,得到的到底是个啥?打印出来可以看到,App就是一个包含多个属性的,组件配置对象,包含了组件的所有信息。

我们将这个对象交给render方法,最终交给h方法来调用,h方法就是createElement方法。

CreateElement

所以createElement方法到底是个什么玩意? createElement 是 Vue 中用于创建虚拟 DOM 节点(VNode)的核心函数。createElement大致源码如下:

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
/**
* 它接受多个参数来描述要创建的节点,并返回一个 VNode 或者 VNode 数组。
* context - 当前组件实例,用于获取组件选项和其他上下文信息。
* tag - 标签名或组件构造函数。
* data - 节点的数据对象,包含属性、样式等信息。
* children - 子节点,可以是字符串、数组或其他 VNode。
*/
export function createElement(
context: Component,
tag: string | Class<Component> | Function | Object,
data: VNodeData | null,
children: any
): VNode | Array<VNode> {
// 省略了许多代码......
// 调用 _createElement 处理实际的 VNode 创建逻辑。
return _createElement(context, tag, data, children, normalizationType);
}

/**
* _createElement 是 createElement 的内部实现,负责处理具体的 VNode 创建逻辑。
* context - 当前组件实例。
* tag - 标签名或组件构造函数。
* data - 节点的数据对象。
* children - 子节点。
*/
function _createElement(
context: Component,
tag?: string | Class<Component> | Function | Object,
data?: VNodeData,
children?: any,
): VNode | Array<VNode> {
let vnode;
// 检查 tag 是否为字符串类型。
if (typeof tag === 'string') {
let Ctor;
// 如果 tag 是保留标签(如 div, span 等),则创建普通元素的 VNode。
if (config.isReservedTag(tag)) {
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
);
} else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
// 如果 tag 是已注册的组件,则创建组件类型的 VNode。
// resolveAsset 函数用于从当前Vue实例的选项中,查找名为 'tag' 的组件
// Ctor是组件的构造器,虽然通常情况是一个js对象,还需要在createComponent中进一步处理
vnode = createComponent(Ctor, data, context, children, tag);
} else {
// 如果 tag 不是保留标签也不是已注册的组件,则将其视为未知标签处理。
vnode = new VNode(
tag, data, children,
undefined, undefined, context
);
}
} else {
// 如果 tag 不是字符串,则直接创建组件类型的 VNode。
// 这种情况出现在创建vue根实例中
// const app = new Vue({ render: (h) => h(App)}).$mount("#app");
vnode = createComponent(tag, data, context, children);
}
// ... 其他处理逻辑 ...
return vnode;
}

总结:createElement 根据 tag 的不同,调用不同的方法(new VNode或者createComponent)生成 VNode

  • 如果tag 是字符串:

    • 如果tag是HTML内置的标签:直接调用new VNode()方法创建VNode

      1
      2
      3
      4
      5
      6
      7
      8
      vnode = new VNode(
      config.parsePlatformTagName(tag),
      data,// 节点的数据对象,包含属性、事件等
      children,// 子节点数组或单个子节点
      undefined,//文本内容,这里为undefined因为不是文本节点
      undefined,
      context//当前的Vue实例上下文
      );
    • 如果tag是已注册的组件标签,则拿到对应的组件构造器Ctor(虽然通常情况是一个js对象),然后再调用createComponent

      1
      2
      3
      4
      5
      6
      if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 如果 tag 是已注册的组件,则创建组件类型的 VNode。
      // resolveAsset 函数用于从当前Vue实例的选项中查找名为 'tag' 的组件
      // Ctor是组件的构造器,虽然通常情况是一个js对象,还需要在createComponent中进一步处理
      vnode = createComponent(Ctor, data, context, children, tag);
      }
    • 如果tag是一个js对象,比如App,则直接调用createComponent方法

      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
          vnode = createComponent(tag, data, context, children);

      `new VNode()`的作用很明显,就是创建一个VNode;从总结中可以看出,传入`createComponent`的是一个**组件配置对象**,那`createComponent`的作用是什么,**如何处理类似App这样的组件js对象?**

      #### createComponent

      createComponent 是**用于创建组件类型VNode**,它最终也会调用`new VNode()`返回据组件的VNode。大致源码如下

      ```js
      // src/core/vdom/create-component.js
      /**
      * Ctor - 组件构造函数或配置对象。
      * data - 组件的数据对象。
      * context - 当前组件实例,提供上下文信息。
      * children - 子节点数组。
      * [tag] - 标签名,如果有的话。
      */
      export function createComponent(
      Ctor: Class<Component> | Function | Object | void,
      data: ?VNodeData,
      context: Component,
      children: ?Array<VNode>,
      tag?: string
      ): VNode | Array<VNode> | void {
      // 如果 Ctor 未定义,则直接返回。
      if (isUndef(Ctor)) {
      return;
      }

      // 获取 Vue 的基础构造器,通常是指向 Vue 本身。
      const baseCtor = context.$options._base;

      // 如果 Ctor 是一个对象,则通过 Vue.extend 方法扩展它,使其成为一个真正的组件构造函数。
      if (isObject(Ctor)) {
      Ctor = baseCtor.extend(Ctor); // 使用 Vue.extend 创建子类构造函数
      }

      // 异步组件处理逻辑(省略)
      // 初始化 data 对象,确保其存在。
      data = data || {};

      // 将组件 v-model 数据转换为 props 和 events(省略)
      // 从 vnode data中提取 props 数据(propsData)(省略)。
      // 提取 listeners,并将原生事件监听器移到 data.nativeOn 中(省略)。
      // 安装组件钩子函数,如 init、prepatch 等(省略)

      // 实例化 VNode 并返回。注意,组件 VNode 没有 children。
      const name = Ctor.options.name || tag;
      const vnode = new VNode(
      //tag属性,用来区别是组件类型的VNode,还是普通VNode,这里以vue-component-显然是组件类型的VNode
      `vue-component-${Ctor.cid}${name ? `-${name}` : ""}`,
      data,
      undefined, // 注意这里没有 children
      undefined,
      undefined,
      context,
      { Ctor, propsData, listeners, tag, children },//提取出的 propsData 被传入组件 VNode 的 componentOptions 中
      asyncFactory
      );
      return vnode;//最终返回一个vnode,这个vnode就是组件的vnode
      }

createComponent 在这里主要做了2件事

  • 把传入的组件对象,使用Vue.extend构造成 Vue 的子类,我们书写组件的时候是这样的

    1
    2
    3
    4
    5
    6
    7
    8
    import HelloWorld from "./components/HelloWorld";

    export default {
    name: "app",
    components: {
    HelloWorld,
    },
    };

    导入的HelloWorld是一个对象,因为是个对象,所以代码逻辑会走到 Ctor = baseCtor.extend(Ctor) 在这里 baseCtorVue 构造函数。 然后执行 baseCtor.extend 将我们传入的对象,转化为构造函数。

  • 实例化 VNode 并返回:在createComponent中,最终还是要调用new VNode()方法创建一个VNode并返回

Vue.extend()

这个api我们在学习非单文件组件的时候就学习过,下面是简要的源码

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
Vue.extend = function (extendOptions: Object): Function {
// 如果 extendOptions 没有提供,则初始化为空对象。
extendOptions = extendOptions || {}

// Super 指向 Vue。
const Super = this

// 获取 Super 的 cid(组件标识符)。
const SuperId = Super.cid

// 获取或初始化缓存构造器对象,用于缓存已经创建过的子类构造函数。(省略)
// 如果已经存在以 SuperId 为键的子类构造函数,则直接返回缓存的实例(省略)

// 确定新组件的名字,优先使用 extendOptions.name,否则使用 Super.options.name。
const name = extendOptions.name || Super.options.name
// 在非生产环境中,如果提供了组件名,则验证其合法性(省略)

// 定义新的构造函数 Sub,它将作为扩展后的 Vue 子类。
const Sub = function VueComponent (options) {
// 调用 _init 方法进行实例化,这是 Vue 实例初始化的入口。
this._init(options)
}
// 设置 Sub.prototype 为 Super.prototype 的副本,实现原型链继承。
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub

// 分配一个唯一的 cid 给新的子类构造函数。
Sub.cid = cid++

// 合并 Super.options 和 extendOptions 来生成 Sub 的 options(省略)

// 将 Super 设为 Sub 的 super 属性,方便访问父类。
Sub['super'] = Super

//省略了许多代码。。。

// 返回扩展后的子类构造函数。
return Sub
}

Vue.extend()的主要作用就是:

  • 根据传入的组件配置对象,创建一个组件构造函数并返回
  • 这个组件构造函数的名称是VueComponent,它的父类是Vue构造函数

调用VueComponent构造函数,就开始了组件实例的创建和初始化流程。不过这个构造函数,不是在createComponent中被直接调用的,而是挂载到createComponent返回的VNode中,也就是组件的VNode中

具体来说,子组件的 VNode 包含了一个特殊的 componentOptions 属性,其中包含了子组件的构造函数,以及其他相关信息如 propsData, children 等。

1
2
3
4
5
6
7
8
9
10
11
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ""}`,
data,
undefined, // 注意这里没有 children
undefined,
undefined,
context,
{ Ctor, propsData, listeners, tag, children }//componentOptions,
asyncFactory
);
return vnode;//最终返回一个vnode,这个vnode就是组件的vnode

那这个构造函数具体是何时被调用呢?

  • 调用父组件的render函数的时候,就会调用这个构造函数,开启子组件实例的创建吗?并不会!
  • 父组件调用render的时候,只会创建子组件的VNode,创建子组件的构造函数,但是不会调用子组件的构造函数。只有当父组件调用_update方法将虚拟DOM转化成真实DOM的时候,在createEle方法(将虚拟DOM转化成真实DOM)中,才会调用子组件的构造函数,创建组件实例。

创建组件实例的时候又发生了什么?

1
2
3
4
const Sub = function VueComponent (options) {
// 调用 _init 方法进行实例化,这是 Vue 实例初始化的入口。
this._init(options)
}

这部分内容在《说说Vue实例挂载过程中发生了什么》中有介绍。

说说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的时候被触发的。
  • initInjectionsinitState 之前执行,因为 inject 可能被 datacomputed 依赖。
  • initProvide 在最后执行,因为它依赖于 datacomputed 的值。
  • 执行created的时候,所有配置属性几乎都初始化完成,能够直接通过this访问dataprops这些对象的属性,但这时候并未完成dom的挂载,因此无法访问到dom元素
  • 通过调用vm.$mount方法实现了dom挂载

initInjections和initProvide

关于initInjectionsinitProvide的详细介绍,在后文《组件传值-provide和inject》部分中。

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等其他属性的初始化做了什么,这里暂时不深入研究。

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) {
//propsData是从父组件中传递过来的值的集合
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);
}
}
}

propsOptions = vm.$options.props,是props配置对象,是我们自定义的,比如

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

vm.$options.propsData,是组件接收到的,父组件传递过来的值,vm._props的值是一个响应式数据,它的属性会被代理到vm上。

initProps无非就是做了这么几件事:

  • 创建一个空对象,赋给vm._props
  • 使用propsOptions校验propsData,并拿到一个一个的值value
  • 调用 defineReactive(props, key, value),将一个一个的值响应式地代理到vm._props
  • 调用proxy(vm, '_props', key),将vm._props上的值代理到vm上

initMethods

简要源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 简化自 Vue 2.x 的源代码
function initMethods(vm, methods) {
const props = vm.$options.props;
for (const key in methods) {
if (process.env.NODE_ENV !== 'production') {
// 开发环境下的一些检查
// 如果值不是一个函数,则报错
if (typeof methods[key] !== 'function') {
warn(`Method "${key}" has type ${typeof methods[key]} in the component definition.`);
}
// 如果和props上的属性冲突,则报错
if (props && hasOwn(props, key)) {
warn(`Method "${key}" has already been defined as a prop.`);
} else if (vm[key] !== undefined) {
//如果值为空则报错
warn(`Method "${key}" conflicts with an existing Vue instance method.`);
}
}
// 将方法绑定到 Vue 实例上
// bind(methods[key], vm)等价于methods[key].bind(vm)
vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
}
}

可以看出initMethods做的事情是非常简单的,约等于直接把方法挂载到vm上。

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
function observe(obj) {  
//如果传入的不是对象或者传入的是null(typeof null返回的也是object)就直接返回
if (typeof obj !== "object" || obj == null) {
return;
}
return new Observer(obj); //返回一个Observer实例,这个实例的value属性就是添加了响应式的数据
}

observe方法调用new Observer(obj),并返回

Observer
1
2
3
4
5
6
7
8
9
10
11
12
13
class Observer {  
constructor(obj) {
this.value = obj;
this.dep = new Dep()
def(obj, '__ob__', this)//this就是observer实例
this.walk(obj);
}
walk(obj) {
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
}

在Observer类的构造器中:

  • 将obj赋给this.value

  • 创建了Dep实例:这个 Dep 实例主要是为了处理数组类型的对象,或对象本身作为一个整体被访问的情况。对于数组,Vue 重写了某些数组方法(如 push, pop 等),以便在调用这些方法时也能够触发依赖的更新,

  • def(obj, '__ob__', this)的作用是,将这个Observer实例,赋给这个obj__ob__属性,这样使用obj.__ob__.dep就能管理obj这一整个对象的订阅者。

  • 调用this.walk方法,将value转化成响应式数据。在walk方法内部:

    • 调用Object.keys方法获得这个对象自己的所有可枚举属性

    • 然后对每个属性都调用defineReactive方法添加响应式

defineReactive
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
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收集依赖,调用dep.depend()方法
if(Dep.target){
dep.depend()
}
//这里不能写成obj[key],否则会陷入无限递归,即在get中触发了get
//返回的是闭包中的value值(也就是函数第三个参数),也就是说再添加getter之前,先把数据取出来
return value
},
set(val) {
// 修改的是闭包中的value值,并没有直接修改obj中的数据。
// 修改obj[key]也会陷入无限递归,因为再set中触发了set
if(value === val){
return
}
value = val
//通知依赖更新
dep.notify();
}
})
}

defineReactive方法内部到底做了些什么呢?其实它主要就做了如下工作:

  • 劫持属性,这是通过Object.defineProperty实现的

  • 其他逻辑

    • 判断value是不是对象,如果是对象,还要递归添加响应式

    • 为每个属性(key)都创建一个Dep实例,用来存储这个属性的subs

    • 触发getter的时候,调用dep.depend(),进行依赖收集

    • 触发setter的时候,调用dep.notify,通知所有订阅者更新

在前面的内容中,我们发现在Observer类的构造函数中,还有defineReative函数中,都使用到了Dep类,调用了主要Dep实例的dep.dependdep.notify()方法,那它到底有什么作用呢?

Dep

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
// 导入 Watcher 类型和一些工具函数
import type Watcher from './watcher'
import { remove } from '../util/index'
import config from '../config'

let uid = 0 // 用于生成 Dep 实例的唯一 ID

/**
* Dep 类是 Vue 响应式系统中的核心部分,它允许多个指令订阅同一个数据属性。
* 每当数据发生变化时,Dep 将负责通知所有订阅者(Watcher)进行更新。
*/

export default class Dep {
// 静态变量,保存当前正在评估的 Watcher 实例。全局唯一,因为同一时间只能有一个 Watcher 在评估。
static target: ?Watcher;

id: number;// Dep 实例的唯一标识符
subs: Array<Watcher>;// 订阅者数组,包含所有监听此依赖的 Watcher 对象,可以理解为一个明星有多少粉丝

/**
* 构造函数初始化 Dep 实例。
*/
constructor () {
this.id = uid++ // 分配一个唯一的 ID 给每个 Dep 实例
this.subs = [] // 初始化订阅者数组为空
}

/**
* 添加一个新的订阅者到 Dep 实例中。
* sub: 新的订阅者(Watcher 实例)
*/
addSub (sub: Watcher) {
this.subs.push(sub) // 将新的订阅者添加到订阅者列表中
}

/**
* 从 Dep 实例中移除指定的订阅者。
* sub :要移除的订阅者(Watcher 实例)
*/
removeSub (sub: Watcher) {
remove(this.subs, sub) // 使用 remove 函数从订阅者列表中移除指定的订阅者
}

/**
* 当某个属性被访问时调用此方法,将当前活跃的 Watcher 添加为该属性的依赖。
*/
depend () {
if (Dep.target) { // 如果存在当前活跃的 Watcher
Dep.target.addDep(this) // 将当前 Dep 添加到该 Watcher 的依赖列表中
}
}

//当属性值(或者对象,数组)发生改变时调用此方法,通知所有订阅者进行更新。
notify () {
const subs = this.subs.slice() // 复制一份订阅者列表以避免在遍历过程中修改原列表
if (process.env.NODE_ENV !== 'production' && !config.async) {
// 如果不是生产环境且非异步模式下,对订阅者列表进行排序以确保正确的触发顺序
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 遍历并调用每个订阅者的 update 方法
}
}
}

// 全局静态变量初始赋值为 null,表示当前没有活跃的 Watcher
Dep.target = null

// 定义一个数组来实现栈结构,用于存储 Watcher 实例
const targetStack = []

/**
* 将一个 Watcher 实例压入栈中,并设置为当前活跃的 Watcher。
* @param target Watcher 实例
*/
export function pushTarget (target: ?Watcher) {
targetStack.push(target) // 将 Watcher 实例加入栈顶
Dep.target = target // 设置为当前活跃的 Watcher
}

/**
* 从栈中弹出顶部的 Watcher 实例,并恢复上一个活跃的 Watcher。
*/
export function popTarget () {
targetStack.pop() // 移除栈顶元素
Dep.target = targetStack[targetStack.length - 1] // 恢复上一个活跃的 Watcher
}
Dep.target

Dep.target表示当前活跃的Watcher实例,同时只能有一个

subs

每个dep实例都有唯一的id和subs(订阅者数组,Watcher数组)

dep.depend

当响应式数据的getter被触发后,这个方法就会被调用,但是这个方法其实是在调用Dep.target.addDep(this)方法,

1
2
3
4
5
depend () {
if (Dep.target) { // 如果存在当前活跃的 Watcher
Dep.target.addDep(this) // 将当前 Dep 添加到该 Watcher 的依赖列表中
}
}

其中传入的this就是dep实例,说白了就是在调用Watcher实例的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
addDep (dep: Dep) {
const id = dep.id//获取dep实例的id
//如果不是新的dep实例
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)//push依赖(dep)到Watcher的newDeps数组
//this.depIds 是 Watcher 实例内部维护的一个 Set 数据结构,用于记录 该 Watcher 所依赖的所有 Dep 实例的 ID。
if (!this.depIds.has(id)) {
//这里的this指向的是Watcher实例
dep.addSub(this)//然后再调用dep的addSub方法,push订阅者(Watcher)到dep的subs数组
}
}
}

总的来说,dep.depend() 方法通过利用全局变量 Dep.target,在数据读取时建立了 DepWatcher之间的双向关联。具体的来说,是Watcher先进行依赖收集,然后deps再收集它的订阅者(Dep.target

dep.notify

当响应式数据改变的时候,调用这个方法通知订阅者更新,遍历并调用每个订阅者(Watcher)的 update 方法

Watcher

dep的订阅者是Watcher,dep类似现实生活中的明星,Watcher就是它们的粉丝,一个dep可以有多个Wather(一个明星可以有多个粉丝),一个Watcher也可以有多个dep(一个粉丝还可以喜欢其他多个明星),那Watcher是如何工作的呢?

大致源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class Watcher {
// 属性定义...

constructor(vm, expOrFn, cb, options, isRenderWatcher) {
// 构造函数:初始化 watcher
}
get() { ... } // 获取值并收集依赖
addDep(dep) { ... } // 添加依赖
cleanupDeps() { ... } // 清理旧依赖
update() { ... } // 数据变化后调用,准备更新
run() { ... } // 真正执行更新
evaluate() { ... } // 懒加载求值(用于 computed)
depend() { ... } // 通知依赖进行依赖收集
teardown() { ... } // 销毁 watcher
}
构造函数

Watcher 类有点长,我们先来看构造函数

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
constructor (
vm: Component, // Vue类/组件 实例
expOrFn: string | Function, // 字符表达式或函数
cb: Function, // 回调函数,收到更新通知时执行
options?: ?Object, // 其他选项
isRenderWatcher?: boolean // 是否为渲染 watcher
) {
this.vm = vm
if (isRenderWatcher) { // 如果是渲染 watcher,对象赋值给 vm._watcher
vm._watcher = this
}
vm._watchers.push(this) // 对象放入 vm 的 _watchers 中
// options
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.before = options.before
} else {
//默认不是深度监听,默认不是用户自定义Wacther,默认不是懒更新,默认是异步
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb // 回调函数
this.id = ++uid // uid for batching // 实例ID
this.active = true //Watcher是否是激活状态,一般都是激活状态

// 初始化 dirty = lazy,主要用于计算属性
this.dirty = this.lazy // for lazy watchers
this.deps = [] // 当前观察的 dep
this.newDeps = [] // 新收集的需要观察的 dep
this.depIds = new Set() // deps 的ID集合
this.newDepIds = new Set() // newDeps 的ID集合
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// 获取getter函数
// expOrFn 转成 getter 函数
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
// 如果不是 lazy 的 watcher(如果不是计算属性watcher) 则立即执行 get 成员方法
this.value = this.lazy
? undefined
: this.get()
}

构造函数接收几个参数:

  • vm: 当前 Vue 实例
  • expOrFn: 要监听的内容,可以是一个字符串 'a.b.c' 或一个函数
  • cb: 数据变化时要执行的回调函数
  • options: 可选配置项(如 deep、lazy、sync)
  • isRenderWatcher: 是否是渲染 watcher(也就是用来更新视图的那个)

构造函数中主要做了这么几件事情:

  • 根据options初始化属性:

    1
    2
    3
    4
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync

    这里 !! 的作用就是把任意值转换成布尔值,防止传进来的是数字、字符串等类型。如果未配置对应属性,则取默认值false

  • 判断是否是渲染 watcher:

    1
    2
    3
    if (isRenderWatcher) {
    vm._watcher = this
    }

    如果是渲染 watcher(即更新视图的 watcher),就把这个 watcher 存到 vm._watcher 上,方便后面更新视图时直接使用。

  • 把当前 watcher 加入实例的 _watchers 数组中

    1
    vm._watchers.push(this)

    vm._watchers,是一个数组,存放这个组件中所有的 watcher:

    • 渲染 watcher(1个)
    • 用户通过 $watch 添加的 watcher(多个)
    • computed 属性对应的 watcher(多个)
  • 设置getter函数

  • 调用get方法

getter

getter函数的作用就是访问响应式数据,触发依赖收集(dep.depend),并返回一个值value

1
2
3
4
5
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}

expOrFn是函数的时候,比如:

1
2
3
new Watcher(vm, function () {
return this.a + this.b
}, cb)

那么 this.getter 就是这个函数本身:

1
2
3
this.getter = function () {
return this.a + this.b
}

expOrFn是字符串:

1
new Watcher(vm, 'a.b.c', cb)

Vue 内部会调用 parsePath('a.b.c') 把它转换成一个函数:

1
2
3
this.getter = function () {
// 等价于:return this.a.b.c
}

不管expOrFn是函数还是字符串路径,最后都会变成一个能从vm取值并返回的函数,这就是 getter

get
1
this.value = this.lazy ? undefined : this.get()

如果不是 lazy watcher(不是计算属性Watcher),就立即执行一次 get() 方法,收集依赖并保存初始值。

创建除了计算属性Watcher以外的所有Watcher时,都会调用get函数,其实get方法不仅会再构造函数中被调用,还会在run方法中被调用。

get函数到底是什么,它做了什么事情?

1
2
3
4
5
6
7
8
9
10
11
12
// Dep类中的代码
// 定义一个数组来实现栈结构,用于存储 Watcher 实例
const targetStack = []
//将一个 Watcher 实例压入栈中,并设置为当前活跃的 Watcher。
export function pushTarget (target: ?Watcher) {
targetStack.push(target) // 将 Watcher 实例加入栈顶
Dep.target = target // 设置为当前活跃的 Watcher
}
export function popTarget () {
targetStack.pop() // 移除栈顶元素
Dep.target = targetStack[targetStack.length - 1] // 恢复上一个活跃的 Watcher
}
1
2
3
4
5
6
7
8
9
10
11
12
13
get () {
// 设置当前 watcher 为目标
pushTarget(this)
let value
try {
value = this.getter.call(vm, vm) // 执行 getter,返回一个值,并触发依赖收集(会调用dep.depend方法)
} finally {
if (this.deep) traverse(value) // 深度监听对象/数组内部变化
popTarget() // this.getter可能触发多个依赖收集,所有依赖收集完毕后,再结束依赖收集
this.cleanupDeps() // 清理不再需要的依赖
}
return value
}

分析上述代码,我们可以得知get方法无非就做了这么几件事:

  • 调用pushTarget(this)把当前Watcher(调用get方法的Watcher)设置为target( Dep.target = this)
  • 调用getter方法更新value触发依赖收集, Dep.target就会被deps收集到subs里
  • 所有依赖收集结束后,调用popTarget() 修改Dep.target,结束依赖收集

简而言之,get能确保当前Watcher的所有依赖,都能收集到这个Watcher,同时还会返回最新的值

update

当响应式数据修改,会触发setter,调用dep.notify方法通知所有Watcher更新,遍历调用Watcher的update方法,那么这个方法做了什么?

1
2
3
4
5
6
7
8
9
update () {
if (this.lazy) {
this.dirty = true // 计算属性标记为脏,下次再重新计算
} else if (this.sync) {
this.run() // 同步执行更新
} else {
queueWatcher(this) // 加入异步队列更新(优化性能)
}
}
run

在update方法中,调用run() 执行的是真正的更新过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
run () {
if (this.active) {// 如果 watcher 是激活状态,大部分情况下都是激活状态
//也就是说,每次run几乎都会触发this.get(), 每次都会调用getter
const value = this.get()// 获取新值并重新收集依赖
if (
//值真的变了 → 需要执行回调
//如果是对象或数组,即使地址没变(值没变),内容可能被修改了(Vue 可以追踪这种变化)→ 需要执行回调
//如果开启了深度监听(如 { deep: true }),即使对象地址没变,也认为内部可能变了 → 需要执行回调
//这三个条件只要满足任意一个,就会执行回调!
value !== this.value ||isObject(value) ||this.deep
) {//如果满足上述条件,还会执行回调函数cb
const oldValue = this.value//存储旧的值,等于Watcher.value
this.value = value// 更新当前值
if (this.user) {//用户定义的watcher,就是通过watch配置的,自定义的watcher
const info = `callback for watcher "${this.expression}"`
//执行回调函数(带错误处理)
invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
} else {
//执行回调函数
this.cb.call(this.vm, value, oldValue)
}
}
}
}

从上面的代码中可得知,run方法无非就做了这么几件事:

  • 调用get更新当前值(this.value),这意味着会调用getter
  • 执行回调函数cb(如果满足判断条件)

3大类Watcher

每个Watcher都有value,getter,get,cb,update,run属性。Watcher又可分为三类,渲染Watcher,计算属性 Watcher,用户自定义Watcher。为什么Watcher只有这3类,因为响应式数据,通常只在,模板,计算属性,watch中被使用。

这三类Watcher的value,getter和cb分别是什么情况呢?首先我们可以看看这三类Watcher是在哪里,如何被创建的。

渲染Watcher
mountComponent

渲染 Watcher在mountComponent(后面会介绍)方法中被创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function mountComponent(){
// 省略其他代码
updateComponent = () => {
.....
//调用_update方法将虚拟dom转化成真实dom并放入页面
vm._update(vm._render(), hydrating)
.....
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
}

// 省略其他代码
  • 可以看到第5个参数 为 true /* isRenderWatcher */; 说明是渲染watcher;

  • new Watcher(vm, updateComponent, noop, { ... }, true) 中,updateComponent 是第二个参数,那么它应该是 expOrFn,最终会赋值给this.getter

  • cbnoop,就是空回调函数的意思。对于渲染Watcher来说,没有实际的回调函数cb

既然**updateComponent是渲染Watcher的getter,那它是如何触发依赖收集的**?返回值又是什么?

_render

我们先来看看updateComponent中调用的_render方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options

// 设置当前渲染上下文
vm.$vnode = _parentVnode

let vnode
try {
// 这里执行了 render 函数
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
// 错误处理
}
// 返回虚拟节点
return vnode
}

这个方法主要做了这么几件事:

  • 从vm.$options中拿到准备好的render函数(render函数由模板解析得到)
  • 调用render函数并返回得到的VNode

补充:vm.$createElement就是我们常说的 createElement 方法。render方法其实就是使用createElement来创建VNode

render方法中会访问响应式数据(如 this.age)

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++')//'age++'是标签体内容
]
);
}

调用render方法 →响应式数据被访问 → 触发 getter → 收集依赖,因此updateComponent是一个规范的getter,它确实能触发依赖收集

_update

调用完render方法得到VNode后,还会调用_update方法,将虚拟DOM转化成真实DOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//此方法有2个形参vnode, hydrating
//第一个是最新的VNode,第二个是一个布尔值,表示是否复用服务端渲染的DOM
Vue.prototype._update = function (vnode, hydrating) {
const vm = this
const prevVnode = vm._vnode
vm._vnode = vnode//更新实例的虚拟DOM

if (!prevVnode) {
// 初次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新阶段
vm.$el = vm.__patch__(prevVnode, vnode)
}
}

其中vm.__patch__就是我们常说的patch方法,在后面的diff算法中有介绍。它的作用是:

  • 首次渲染时:把虚拟 DOM 转换成真实 DOM,其实就是createEle方法,并挂载到页面
  • 更新时:对比新旧虚拟 DOM,进行高效的 Diff 算法,只更新变化的部分
  • 从中可以看出update方法并没有返回值(或者说返回值是undefined),这就意味着updateComponent方法没有返回值,这就意味着渲染Watcher的getter没有返回值,这就意味着渲染Watcher的value始终是undefined也就是说,渲染Watcher的value不重要
总结
  • 渲染Watcher没有cb,只有getter;而且getter没有返回值,说明渲染Watcher的value不重要,更新后只触发getter

  • 如果响应式数据在模板中被使用,当创建渲染Watcher的时候(组件初次挂载),会在构造函数中调用this.get方法:

    • this.get内部会调用pushTarget(this)将渲染Watcher设置为target(将渲染Watcher赋给Dep.target
    • 再调用this.getter方法,也就是updateComponent方法。
    • updateComponent方法内部会调用render方法,访问响应式数据,触发getter:调用dep.depend()方法,其在内部调用Dep.target.addDep(this)方法,让渲染Watcher订阅dep
    • addDep方法内部调用dep.addSub(this),让dep收集渲染Watcher到subs。
    • 调用完render方法,收集好依赖,返回虚拟DOM后,updateComponent还会调用update方法,将虚拟DOM转化成真实DOM
  • 我们可以得出结论:

    • 创建渲染Watcher的时候,渲染Watcher会被赋给Dep.target
    • 我们常常说的render,在创建渲染Watcher的时候会被调用,后续渲染Watcher调用run方法,也会调用get->getter->render方法
    • render函数执行的时候会触发依赖收集,render函数被调用的时候,就是渲染Watcher被dep收集的时候
    • render方法只在this.get中被调用,而渲染Watcher调用get方法的时候,就会将渲染Watcher赋给Dep.target,所以调用render方法的时候,deps收集的Watcher一定是渲染Watcher
  • 当响应式数据改变,调用dep.notify(),遍历所有Watcher,调用它们的update方法,最终会调用它们的run方法:

    • run方法内部会再次调用this.get()方法,其内部会调用pushTarget(this) ,将渲染Watcher设置为target(将渲染Watcher赋给Dep.target)
    • this.get()方法内部还会调用this.getter方法,对渲染Watcher来说,也就是调用updateComponent方法,这个方法内部会调用render方法
    • render方法再次访问响应式数据,根据最新的值,创建新的VNode;由于已经收集过依赖,所以再次访问响应式数据不会重复收集依赖。
    • 调用完render方法,返回新的VNode后,updateComponent还会调用update方法,将虚拟DOM转化成真实DOM,这里就涉及到diff算法了。

简单的来说,在vue中,数据改变,视图更新,是因为调用了渲染Watcher的update方法,这个方法将渲染Watcher放入异步更新队列(后续在this.$nextTick原理中会介绍)。在微任务阶段清空异步更新队列,调用队列中所有Watcher的run方法。当调用渲染Watcher的run方法,run方法会调用get->getter方法(也就是updateComponent方法),getter方法中会调用render方法创建新的VNode,再调用patch方法根据新旧虚拟DOM更新DOM,DOM更新了,视图就更新了。

计算属性Watcher
配置方式

计算属性的配置方式,有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
new Vue({
data() {
return { firstName: 'John', lastName: 'Doe' };
},
computed: {
fullName() {
console.log('Computing fullName');
return this.firstName + ' ' + this.lastName;
},
fullName2: {
get() {
console.log('Getting fullName');
return this.firstName + ' ' + this.lastName;
},
set(newValue) {
//不能直接修改计算属性,尝试修改计算属性会修改计算属性的依赖
console.log('Setting fullName');
const names = newValue.split(' ');
this.firstName = names[0];
this.lastName = names[1];
}
}
}
});
initComputed

计算属性Watcher在initComputed的时候被创建,initComputedinitState函数中被调用,我们来看看initComputed做了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function initComputed (vm: Component, computed: Object) {
const watchers = vm._computedWatchers = Object.create(null)

for (const key in computed) {
const userDef = computed[key]
// 如果computed[key]是函数,则直接用来当作计算属性Watcher的getter,否则将computed[key].get作为getter
const getter = typeof userDef === 'function' ? userDef : userDef.get
// 为每一个计算属性,创建一个 computed watcher
watchers[key] = new Watcher(
vm,
getter,
noop,//计算属性Watcher也没有实际的回调函数cb,noop就是一个空函数
{ lazy: true } // 关键点:lazy 表示懒执行,不立即求值
)
// 定义响应式属性
defineComputed(vm, key, userDef)
}
}

我们来看看initComputed到底做了什么

  • 遍历每个计算属性,在给每个计算属性创建Watcher之前,先确定每个计算属性的getter。拿到每个计算属性的value,如果这个value是一个函数,则直接把它作为计算属性Watcher的getter,否则把value.get作为getter,无论如何,得到的getter的格式形如:

    1
    2
    3
    function() {
    return this.firstName + ' ' + this.lastName;
    }

    可以看出,计算属性的getter的主要作用是求值,由于访问了响应式属性还会触发依赖收集

  • 确定好getter之后,为每个计算属性创建一个Watcher,并存储在vm._computedWatchers[key]中,从代码中可以看出,计算属性Watcher也没有实际的回调函数cb,它的cb是一个空函数(noop),说明计算属性Watcher的cb不重要

  • 对于 计算属性(computed) 来说,它对应的 Watcher 有两个特殊标志

    • lazy:表示是否延迟求值(即不立即调用 get() 方法获取值)

    • dirty:表示当前值是否是“脏”的(需要重新计算)

    • 因为计算属性Watcher的lazy属性为true,这就是意味着,创建计算属性Watcher的时候,并不会立即调用this.get方法取值,也就不会触发收集依赖。只有在模板或其他地方访问它的时候才会真正去求值

      1
      2
      3
      4
      constructor (){
      ....
      this.value = this.lazy? undefined: this.get()
      }
  • 创建完计算属性的Watcher后,调用defineComputed方法

defineComputed

我们再来看看defineComputed到底做了什么

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
// 辅助变量和函数
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
};
function noop () {}

function defineComputed (target, key, userDef) {
const shouldCache = true; // 在 Vue 2 中 computed 默认都支持缓存
const isFunction = typeof userDef === 'function'; // 判断用户定义的是函数还是对象形式
// 定义 getter
if (isFunction) {//如果userDef是函数
//调用createComputedGetter得到计算属性的getter
//如果userDef是函数。说明一定没有定义set
sharedPropertyDefinition.get = createComputedGetter(key);
} else {
//如果不是函数,但是有get方法,则还是调用createComputedGetter得到计算属性的getter
sharedPropertyDefinition.get = userDef.get
? createComputedGetter(key)
: noop;
//如果定义了set,直接赋给sharedPropertyDefinition.set
sharedPropertyDefinition.set = userDef.set || noop;
}

// 使用 Object.defineProperty 把计算属性代理到 vm 上
Object.defineProperty(target, key, sharedPropertyDefinition);
}

可以看出,defineComputed的最终目的是,使用Object.defineProperty把计算属性代理到vm上方便访问的,但是在这之前,还需要确定计算属性的get和set

  • 计算属性的get等于createComputedGetter (key)
  • 计算属性的set等于userDef.set ,如果没有配置set,则set为noop,空函数。

要注意的是,计算属性虽然会被代理到vm上,就像一般响应式数据一样,但是计算属性没有自己的dep

createComputedGetter

我们来看看createComputedGetter是如何构造计算属性的get

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
// 创建 computed 属性的 getter 函数
function createComputedGetter (key) {
return function computedGetter () {
//先查找是否存在对应的watcher
const watcher = this._computedWatchers && this._computedWatchers[key];
//如果没有找到直接退出
if (!watcher) {
return;
}
// 找到计算
// 如果是 dirty 的,就重新求值
if (watcher.dirty) {
watcher.evaluate();//在这个过程中,会触发依赖收集,计算属性Watcher会被dep收集为订阅者
}

// 收集依赖:将当前 Dep.target(渲染 watcher)添加到该 computed watcher 的依赖中
// 主要做的是一个依赖收集的工作
if (Dep.target) {
watcher.depend();//这段代码也是一个依赖收集的工作,让计算属性Watcher的dep也收集渲染Watche为订阅者
}

// 如果不是dirty的,就直接返回缓存的值
// 如果是dirty,则先重新求知后再返回
// 可以看出返回的值是计算属性watcher存储的值
return watcher.value;
};
}

createComputedGetter会立即返回一个具名函数computedGetter,在computedGetter 函数的内部,或者说计算属性get的内部,是这样工作的:

  • 先在this._computedWatchers中查找当前key(计算属性)是否存在对应的watcher,不存在直接退出

  • 找到计算属性的Watcher后,根据Watcher.dirty属性判断计算属性是否是脏的,如果是,则调用watcher.evaluate()

  • 如果有Watcher正在收集依赖,则调用watcher.depend()方法

  • 最后,返回watcher.value。这就说明,计算属性的get返回的值,本质就是对应的计算属性watcher.value,而watcher.value的更新,则是由watcher.get负责的,要把计算属性的get和计算属性Watcher的get区分开来啊。确定计算属性的set就没那么麻烦了,如果自己定义了set,直接就当作计算属性的set。

来看看watcher.evaluate()具体做了什么?

1
2
3
4
5
6
7
8
//Watcher的 evaluate 简要源码如下
evaluate () {
// 重新取值,把计算结果缓存下来,这里的this指向计算属性Watcher
// 因为 get 还会调用 pushTarget 方法,也就是说哪个Watcher调用get方法,哪个Watcher就会被dep收集
// 在这里计算属性Watcher会被dep收集
this.value = this.get()
this.dirty = false //标记为“干净”,下次访问时如果依赖未变,就直接返回缓存值,不再重新计算
}

它无非就做了2件事:

  • 调用计算属性的get方法,计算新的值并收集依赖
  • 修改this.dirty为false

get方法的内部,会将当前计算属性Watcher赋给Dep.target,然后调用getter,类似:

1
2
3
function() {
return this.firstName + ' ' + this.lastName;
}

访问响应式数据,于是计算属性Watcher被dep收集到dep.subs,这是第一次依赖收集

watcher.depend()做了什么,还要此时的Dep.target为什么是渲染Watcher?

  • 如果模板中使用了计算属性,于是在组件首次挂载->创建渲染Watcher->调用get方法->将渲染Watcher设置为Dep.target->render函数的时候,就会访问计算属性,触发计算属性的get

  • 由于计算属性初始是dirty的,所以首次触发计算属性的get,会调用watcher.evaluate(),进而调用watcher.get()进行计算属性Watcher的依赖收集。

  • 计算属性Watcher的依赖收集结束后,Dep.target切换为渲染Watcher,为什么是渲染Watcher?创建渲染Watcher的时候,在构造函数内会调用this.get方法:

    • 这个方法内部,渲染Watcher会赋给Dep.target
    • 然后再调用this.getter,这个方法内部会调用render函数,当访问到计算属性,触发计算属性的get,于是调用计算属性watcher的evaluate方法,然后就回到了先前的分析了。
  • 然后调用watcher.depend方法,遍历计算属性Watcher的所有依赖(dep)

    1
    2
    3
    4
    5
    6
    7
    8
    //Watcher的 depend 简要源码如下
    depend () {
    let i = this.deps.length
    //遍历计算属性的所有依赖,调用它们的depned方法
    while (i--) {
    this.deps[i].depend()
    }
    }

    调用它们的depend方法

    1
    2
    3
    4
    5
    6
    //dep的depend方法
    depend () {
    if (Dep.target) { // 如果存在当前活跃的 Watcher
    Dep.target.addDep(this) // 将当前 Dep 添加到该 Watcher 的依赖列表中
    }
    }

    简单的来说,在访问计算属性时,**watcher.depend() 会让渲染 Watcher 去订阅计算属性所依赖的底层数据的 dep,而计算属性 Watcher 本身也是这些 dep 的订阅者。因此,这些 dep 的 subs 列表中会同时包含计算属性 Watcher 和渲染 Watcher。**

  • 这么做的效果就是,如果某个计算属性在模板中使用了,当其某个依赖的改变后,会同时通知计算属性Watcher更新和渲染Watcher更新;

    • 对于计算属性Watcher,调用update方法,只会修改dirty属性为true,不进行实际的更新操作;

      1
      2
      3
      4
      5
      6
      7
      8
      9
      update () {
      if (this.lazy) {
      this.dirty = true // 计算属性标记为脏,下次再重新计算
      } else if (this.sync) {
      this.run() // 同步执行更新
      } else {
      queueWatcher(this) // 加入异步队列更新(优化性能)
      }
      }
    • 但是对于渲染Watcher,调用update方法回将其放入异步更新队列。然后在微任务阶段,清空异步更新队列的时候,调用渲染Watcher的run->get->getter->render方法,从计算属性中取值,触发计算属性的getter

      • 调用watcher.evaluate()方法(watcher是从this._computedWatchers中找到的),watcher.evaluate()内部会调用this.get()获取新的值,并将dirty更新为false,
      • 最后再返回最新的值。

      然后调用update->patch->patchVNode方法给DOM打补丁,更新视图。

用户自定义Watcher
配置方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new Vue({
data() {
return { message: 'Hello' };
},
watch: {
message(newVal, oldVal) {
console.log('Message changed from ' + oldVal + ' to ' + newVal);
},
news:{
handler(newVal, oldVal) {
console.log('初始值是:', newVal);
},
immediate: true
}
}
});
initWatcher

用户自定义Watcher在initWatch的时候被创建,而initWatch在initState方法中被调用。

1
2
3
4
5
6
7
8
9
//initWatch简要源码
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
//为watcher中的每个属性都创建一个Watcher
//通常走这步
createWatcher(vm, key, handler)
}
}
1
2
3
4
5
6
7
8
9
10
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
return new Watcher(vm, expOrFn, handler, mergeOptions({
user: true
}, options))//其中expOrFn是一个key,即字符串
}

分析上述代码,我们能得到以下结论:

  • watch配置对象中的每一个属性都会创建一个Watcher,这一点就非常像计算属性Watcher

  • 用户自定义Watcher的expOrFn,就是watch配置对象的key(一个字符串),也就是说自定义Watcher的getter是需要通过转化得到的,cb通常就是watch.key

    1
    2
    3
    function getter(){
    return this.message
    }
  • 用户自定义Watcher的user属性为true

  • 因为自定义Watcher的lazy属性不为true,所以创建自定义Watcher的时候,就会依次调用:get -> getter,调用getter方法会访问响应式数据,从而触发依赖收集。

    1
    2
    3
    4
    5
    watch: {
    message(newVal, oldVal) {
    console.log('Message changed from ' + oldVal + ' to ' + newVal);
    }
    }

    在上面这个例子中,message对应的自定义Watcher,就会被this.message属性收集为subs(订阅者)。

  • 用户自定义Watcher区别于前面2类Watcher,它有自己的cb,是用户定义的,当自定义Watcher的依赖更新后,用户自定义的Watcher就会调用update->run方法,然后在run方法中调用cb

    1
    2
    3
    4
    5
    6
    7
    8
    if (this.user) {//用户定义的watcher,就是通过watch配置的,自定义的watcher
    const info = `callback for watcher "${this.expression}"`
    //执行回调函数(带错误处理),并传入参数
    invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
    } else {
    //执行回调函数,并传入参数
    this.cb.call(this.vm, value, oldValue)
    }
  • 我们自定义Watch的时候还能拿到新旧值,这说明对于用户自定义Watcher,它的value也是有意义的。

对比
Watcher 类型update() 是否调用 run()?是否有回调 cb?如何处理更新
渲染 Watcher✅ 是❌ 否(内部机制处理更新)放入队列,最终调用 run() 更新视图
用户 Watcher(watch 选项)✅ 是✅ 是放入队列,调用 run() 执行回调
计算属性 Watcher❌ 否❌ 否仅标记为 dirty = true,下次访问时重新计算

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()
}

阅读上面代码,我们发现vm.$mount无非是那么几件事

  • 判断el属性是否为空,如果非空,则捕获对应的dom,然后判断这个dom是不是body或者html,如果是则报错

  • 判断配置对象中是否存在render函数,如果存在,则直接调用mountComponent方法

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

    • 判断配置对象中是否有template属性,我们期望是一个html字符串,但如果这个字符串是id选择器,我们则获取对应的html结构作为模板字符串(idToTemplate)。

    • 实际还可能是dom对象,我们把这个dom对象的innerHTML作为模板

    • 如果既没有render也没有template,那就必须有el,通过getOuterHTML(el)得到的值来充当template,包括了元素本身以及其内部的所有 HTML 内容。

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

    • 解析,编译模板字符串,得到render函数,挂载到配置对象options上,再调用mountComponent开始开始渲染

概括的来说,vm.$mount就做了2件事情,准备render函数,调用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
51
52
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
//这个传入的 DOM 元素(el)就是组件将来要挂载到的位置。
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)
}
}
// 创建渲染Watcher
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
// 数据更新引发的组件更新
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
//vm.$vnode` 是组件实例上的一个属性,表示该组件在父组件 VNode 树中的描述节点(即父组件中创建的组件 VNode)
//在父组件的render函数中会给子组件创建组件 VNode,这个 VNode 会赋值给子组件的 `$vnode` 属性
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
//最终返回创建好的组件实例
return vm
}

mountComponent无非就是做了这么几件事

  • 先判断 vm.$options.render是否存在,如果不存在的话就让它等于 createEmptyVNode
  • 执行beforeMount钩子
  • 准备好updateComponent,创建渲染Watcher

从上述代码中可以看出,在beforeMount钩子被调用的时候,模板已经编译完毕,render函数已经准备好了,不过还没有被调用。

  • render函数准备好
  • 调用beforeMount
  • 调用render函数生成虚拟dom
  • 调用update函数将虚拟dom转化成真实dom挂载到vm.$el
  • 调用mounted方法

在创建渲染Watcher的时候,在构造函数中,updateComponent会立即执行,也就是说会调用_render函数;再调用_update方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Vue.prototype._render = function (): VNode {
const vm: Component = this
//提取出render函数
const { render, _parentVnode } = vm.$options

// 设置当前渲染上下文
vm.$vnode = _parentVnode

let vnode
try {
// 这里执行了 render 函数
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
// 错误处理
}
// 返回虚拟节点
return vnode
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//此方法有2个形参vnode, hydrating
//第一个是最新的VNode,第二个是一个布尔值,表示是否复用服务端渲染的DOM
Vue.prototype._update = function (vnode, hydrating) {
const vm = this
const prevVnode = vm._vnode
vm._vnode = vnode//更新实例的虚拟DOM

if (!prevVnode) {
// 初次渲染
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// 更新阶段
vm.$el = vm.__patch__(prevVnode, vnode)
}
}

其中vm.__patch__就是我们常说的patch方法,在后面的diff算法中有介绍。它的作用是:

  • 首次渲染时:把虚拟 DOM 转换成真实 DOM,其实就是createEle方法,并挂载到页面
  • 更新时:对比新旧虚拟 DOM,进行高效的 Diff 算法,只更新变化的部分

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++')//'age++'是标签体内容
]
);
}
  • createElement方法是用来创建VNode的,render方法返回的其实就是虚拟DOM,这一点和React中完全相同
  • 调用父组件的模板render函数时,访问了父组件实例的age属性,赋值给子组件的props.age,这个过程中触发age属性的getter于是收集父组件自身的render函数为依赖(就是渲染Watcher)
  • 调用父组件的模板render函数,遇到子组件标签时,会调用createElement方法,根据子组件的配置,创建子组件的VNode(这部分内容前面介绍过) ,然后再将子组件VNode转化成真实DOM的时候,会调用子组件构造函数,其会调用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属性并没有收集子组件Watcher为订阅者,为什么父组件更新age属性,子组件视图也会更新呢?

  • 当父组件中的age属性改变,会触发对应的setter,然后通知依赖更新,其中的依赖就包括父组件渲染Watcher

  • 父组件渲染Watcher最终会调用run方法,这个方法会调用render函数,创建新的组件vnode(props的值是新的)

  • 在patchVnode阶段,更新组件实例,修改子组件的_props属性

    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
    function patchVnode (oldVnode, vnode) {
    if (oldVnode === vnode) {
    return;
    }
    //判断是否是组件 VNode
    if (isRealComponent(vnode)) {
    // 组件 VNode 有自己的一套 prepatch 流程
    const prevComponent = oldVnode.componentInstance;
    const hook = vnode.data?.hook;
    if (hook && hook.prepatch) {
    //这是 Vue 内部定义的一个钩子函数,用于处理组件更新逻辑
    hook.prepatch(oldVnode, vnode); //在这里调用 updateChildComponent
    }

    return;
    }
    //不是组件VNode的时候的处理逻辑
    //..........
    }
    prepatch(oldVnode, vnode) {
    const options = vnode.componentOptions;
    const childInstance = vnode.componentInstance = oldVnode.componentInstance;
    // 👇 更新 props、listeners、插槽等
    updateChildComponent(
    childInstance,
    vnode.propsData,
    vnode.listeners,
    vnode,
    false
    );
    },
    // src/core/instance/lifecycle.js
    export function updateChildComponent(
    vm: Component,
    propsData: ?Object,
    listeners: ?Object,
    parentVnode: VNode,
    renderChildren: ?Array<VNode>
    ) {
    const props = vm.$options.props;
    if (props) {
    toggleObserving(false);
    const oldProps = vm._props;
    for (const key in props) {
    const prop = props[key];
    const newVal = validateProp(key, props, propsData, vm);
    const oldVal = oldProps[key];
    if (newVal !== oldVal) {
    vm._props[key] = newVal; // 👈 更新 props
    }
    }
    toggleObserving(true);
    }
    // 更新监听器和插槽...
    }
  • 由于this._props是响应式的,所以会自动触发视图更新。

简单的来说,父组件修改传递给子组件的数据,子组件视图也会更新,**是因为父组件内部重新调用了render方法,创建了新的组件Vnode,然后在patchVnode的时候,会更新组件实例的_props属性(this._props)**。

流程:父组件修改数据 → 触发重新渲染(render)→ 创建新的组件 VNode → 在 patchVnode 阶段触发组件的 prepatch 钩子 → 调用 updateChildComponent → 更新子组件 _props → 子组件视图更新。

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

$emit 触发自定义事件

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

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

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

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

要注意的是,给组件添加的事件监听是自定义事件,因为组件标签不是原生标签,无法添加原生事件监听,也就没有原生事件对象,所以传递给回调函数的是子组件传递过来的值,而不是原生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') //注意的是,这里不是$emit

本质就是要找到一个两个兄弟组件都能访问到的vue实例,在这个实例上注册事件监听,同时也在这个实例上触发事件,本质和emit是一样的(父组件在子组件实例上添加事件监听,子组件通过自己的实例(this)调用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, // 非响应式,因为this.color的值只是个普通类型
userInfo: this.userInfo, // 响应式,因为this.userInfo的值是个对象,而vue中对象是递归添加响应式的
};
}
};
1
2
3
4
5
6
7
export default {
// 如果多个祖先组件都提供了同名的属性,那么最接近的祖先组件提供的属性,会被优先使用(就近原则)。
inject: ['color','userInfo'],
created () {
console.log(this.color, this.userInfo)
}
}
深入理解

如果父组件通过Provide传递的是一个基本数据类型,在子组件内接收了,后续即便父组件修改这个基本数据类型,子组件也不会更新,为什么?但如果父组件通过Provide传递的是一个对象,这一情况就完全不同?

我们先看看initProvide的源码:

1
2
3
4
5
6
7
8
export function initProvide(vm: Component) {
const provide = vm.$options.provide
if (provide) {
vm._provided = typeof provide === 'function'
? provide.call(vm)//使用vm调用provide函数
: provide
}
}

可以看出initProvide的源码非常简单:

  • 如果组件定义了Provide属性,且值是一个函数,则使用call(vm)调用这个函数,确保this指向准确。
  • 最后将函数调用的返回值赋给vm._provided属性。

再看看initInjections的源码

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
// src/core/instance/inject.js
export function initInjections(vm: Component) {
const result = resolveInject(vm.$options.inject, vm); // 解析 inject 配置,拿到对应的值
if (result) {
toggleObserving(false); // 关闭深度响应式观察
Object.keys(result).forEach(key => {
defineReactive(vm, key, result[key]); // 设置为响应式
});
toggleObserving(true); // 恢复深度响应式观察
}
}

// resolveInject 函数逻辑
function resolveInject(inject: any, vm: Component): ?Object {
if (inject) {
//创建一个空对象result,用来存储对应的值
const result = Object.create(null);
const keys = Object.keys(inject);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const provideKey = inject[key].from;
let source = vm;
//双重循环,每个key都向上查找祖先组件是否provide对应的值
while (source) {
//如果这个祖先组件实例有_provided属性(也就是provide了值),且source._provided中提供了特定的值
if (source._provided && hasOwn(source._provided, provideKey)) {
// 找到匹配的 provide 值,直接取出来然赋给result[key]
result[key] = source._provided[provideKey];
break;
}
//如果没找到,继续向上查找父组件
source = source.$parent;
}
//如果在所有祖先组件中都没找到,但设置了默认值
if (!source && "default" in inject[key]) {
// 未找到时使用默认值
const provideDefault = inject[key].default;
// 如果默认值是函数,则指定vm调用这个函数
result[key] = typeof provideDefault === "function"? provideDefault.call(vm): provideDefault;
} else if (!source && process.env.NODE_ENV !== "production") {
warn(`Injection "${key}" not found`, vm);
}
}
return result;
}
}

initInjections无非就是做了这么几件事:

  • 从祖先元素取值:调用 resolveInject(vm.$options.inject, vm),向上查找祖先组件的 provided 属性,找到匹配的 provide 值,最终返回以一个result对象。大致步骤如下:

    • 创建一个空对象result,遍历inject配置对象中的每个属性,从当前组件实例开始:

      • 检查这个组件实例是否提供了对应的值,如果提供了,则把这个值取出来,存到result中

        1
        result[key] = source._provided[provideKey]
      • 如果没有找到,则继续去下一个祖先元素中查找,类似原型链查找

      • 如果在所有祖先元素中都没有找到,则检查inject[key]中是否提供的默认值,如果提供了则使用默认值,存到result中

      • 如果默认值都没提供,则直接报错

  • 代理到组件实例上并添加响应式:使用 defineReactive(vm, key, value) 将result上的属性代理到vm上并添加为响应式,但 不进行深度响应式处理(即嵌套对象的属性不会被劫持)。

ok,分析完源码后,我们来尝试解决开始提到的问题

  • 所以说,即便父组件Provide的值被修改了

    1
    2
    3
    4
    5
    6
    7
    provide() {
    //使用data或者computed中的数据
    return {
    color: this.color, // 非响应式,因为this.color的值只是个普通类型
    userInfo: this.userInfo, // 响应式,因为this.userInfo的值是个对象,而vue中对象是递归添加响应式的
    };
    }

    比如this.color修改了 ,也并不会重新Provide这个值,所以如果父组件通过Provide传递的是一个基本数据类型(比如this.color),在子组件内接收了,后续即便父组件修改这个基本数据类型,子组件实例vm上的color也不会改变。

  • 但是如果父组件通过Provide传递的,是一个对象,由于在vue中响应式是递归添加的,所以这个对象是个响应式对象,而且由于传递的是一个引用,其实父子组件是共用这个响应式对象的,如果子组件中在模板中使用了这个对象,则子组件的渲染Watcher会被它收集为依赖,这样即便在父组件内修改这个对象,在子组件的视图也会更新。

与props的比较

在vue2中,inject的写法和props的写法完全相同

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
//数组写法
props: ['name']
inject: ['theme']
//对象写法
props: {
name: String
}
inject: {
theme: String
}
//高级写法
props: {
age: {
type: Number,
default: 18,
required: false
}
}
inject: {
user: {
type: Object,
default: () => ({ name: 'Guest' }),
required: true
}
}

父组件给子组件通过传递props传递的数据,就是vm.$options.propsData,本身也不是个响应式对象,父组件provide的数据,也不是一个响应式的对象

1
2
3
4
5
createElement('Child', { // 子组件 Child,绑定 props.age
props: {
age: this.age
}//这部数据就是propsData
}),
1
2
3
4
5
6
7
provide() {
//使用data或者computed中的数据
return {
color: this.color, // 非响应式,因为this.color的值只是个普通类型
userInfo: this.userInfo, // 响应式,因为this.userInfo的值是个对象,而vue中对象是递归添加响应式的
};//返回的对象就是vm._provided,也就是提供的对象
}

它们本身都类似

1
2
3
4
{
color: this.color,
userInfo: this.userInfo,
};

也就是父组件从自己身上取值,然后存到一个对象身上,在这个取值的过程,其实是会丢失响应式的,但如果取出的是一个对象,比如userInfo,由于在vue中响应式是递归添加的,所以这个对象userInfo还是个响应式对象。

  • 通过props传值的时候,由于是在模板中使用响应式数据,父组件的响应式数据会收集父组件的渲染Watcher为订阅者,但是通过provide传值,真的就只是传了一个值(前面介绍过)

  • 后续initProps,子组件会把propsData上的数据代理到vm._props上并添加响应式,最终代理vm上方便访问。通过vm直接访问props接收的数据,数据源是vm._props,而vm._props的数据源是闭包中(defineReactive)的一个一个value

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const props = vm._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);
    }
    }
  • 后续initInjections,子组件从祖先组件的vm._provided上取值,并存储到result对象上,由于result单纯是浅拷贝一些祖先组件上_provide的值,所以result对象也是一个普通的对象,后续把result上的数据直接代理到vm上并添加响应式。直接通过vm访问inject的数据,数据源也是闭包中的数据result[key]

    1
    2
    3
    Object.keys(result).forEach(key => {
    defineReactive(vm, key, result[key]); // 设置为响应式
    });
  • 也就是说,通过props或者inject接收的数据,最终都会代理到vm上方便访问,且都会变成响应式的

  • 通过props传递的即便是一个基本数据类型,在父组件中修改了,子组件视图也会更新;因为会调用父组件的render方法,创建新的组件VNode,然后再patchVNode阶段,更新组件实例的_props,由于_props是响应式的,所以能触发子组件视图更新。

  • 但是如果通过provide传递一个基本数据类型,在父组件中修改了,子组件视图也不会更新。因为不会触发子组件的更新过程

Vuex

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

谈谈对el和template属性的理解

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

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

  • 虽然el属性是vue根实例独有的,但它也不是什么优先级很高的东西,如果创建vue根实例同时配置了el和template属性,则template将替换el指定的容器(拜拜了el),成为模板(可以参考vm.$mount源码,template属性优先级更高)

  • 不过要注意的是nodejs开发环境中,通过import导入的vue是精简版的,没有模板解析器的,模板解析器被单独提取出来,作为一个开发环境的包,只在打包的时候发挥作用,**只用来处理.vue文件中的template**,不会包含到最终文件中,从而减小最终文件的体积。所以在创建vue根实例的时候不能使用template,因为打包后的文件中已经没有模板解析器了,所以无法借助它实现在页面中自动插入<App></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函数创建完毕,但是render函数还未调用,还没生成虚拟dom,此时展示的还是旧的页面(未经编译的页面)
  • mounted:此时render函数已经被调用,而且虚拟 DOM 已转换为真实 DOM,挂载到页面上,此时对DOM的操作是有效的。
  • beforeUpdate:此时数据是新的,页面展示的内容是旧的,因为vue视图是异步更新的,关于异步更新这一点,可以参考后文《说说你对nextTick的理解》
  • updated: 此时新旧虚拟dom比较完毕,页面已更新。
  • beforeDestroy:当执行beforeDestroy的钩子的时候,Vue实例就已经从运行阶段进入销毁阶段,但身上所有的data和methods,以及过滤器、指令等,都处于可用状态,还未真正执行销毁的过程
  • destroyed: 完全销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器;并不能清除DOM,仅仅销毁实例。所以页面并不会改变,但是变得无法响应交互。

深入理解

数据请求在created和mouted的区别

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

对于存在子组件的情况,为什么先执行父组件的created钩子,再执行子组件的createdmounted钩子,最后再执行父组件的mounted钩子

  • 在创建父组件实例的时候,在调用完initState之后,就会调用父组件的created钩子
  • 然后在mountComponent中,创建父组件的渲染Watcher的时候,就会调用render方法,创建父组件的VNode
  • 在父组件的render方法中,还会调用createComponent方法创建子组件的VNode子组件的Vnode中挂载了子组件的构造函数
  • 然后调用patch方法,递归将父组件的VNode转化成真实DOM,并挂载到指定的DOM元素上;在这一过程中,对于父组件VNode中包含的子组件VNode,会调用子组件的构造函数,开启子组件的构建流程,因此会调用子组件的createdmounted钩子
  • 然后调用父组件的mounted钩子

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

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

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

响应式

当更改响应式数据时,视图会随即自动更新,关于响应式如何实现,参考《说说Vue实例挂载过程中发生了什么》其中有详细的介绍。

和生命周期函数的联系:

  • 如果一个组件内的某个响应式属性没有任何依赖,即便修改这个响应式属性,也不会触发组件的beforeUpdate,updated钩子
  • updated 只在组件自身的 DOM 或子组件的 DOM 被重新渲染后才会触发,简单的来说,只有视图更新了,beforeUpdate,updated才会触发,后者说,beforeUpdate,updated指的就是视图更新前后。
  • 如果修改的数据未影响到模板中的任何部分(例如只是修改了一个未被使用的变量),则不会触发beforeUpdate,updated

双向绑定

双向绑定,是数据变化驱动视图更新,视图更新触发数据变化。其实就是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.observable你有了解过吗?说说看

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

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

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
function observe(obj){
Object.keys(obj).forEach((key)=>{
defineReactive(obj,key,obj[key])
})
}
function defineReactive(obj,key,value){
if(typeof value == 'object'){
observe(value)
}
const dep = new Dep()
Object.defineProperty(obj,key,{
get(){
if(Dep.target){
dep.addDep(Dep.target)
}
return value
},
set(val){
if(val===value){
return
}
val = value
dep.notify()
}
})
}

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
}
}
}

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

我们从一个例子开始

定义一个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()**来给新增属性添加响应式,这2个api的参数是一样的

Vue.set( target, key, value )

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

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

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

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

说说你对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,为什么是arg不是args,因为传递给指令的参数只能有一个,而修饰符却可以有多个。
    • 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的时间内已经点击过了,调用event.stopImmediatePropagation()
//它不仅会阻止事件继续沿 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会自动阻止多次注册相同插件,只会注册一次。

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文件数量变多了,每个文件的体积也变小了,是因为Webpack / Vite 等构建工具会将每个懒加载的组件打包成一个独立的 chunk(代码块)。这样,index.html引入的的jscss文件的体积也会变小,因为只包含首屏组件需要的js,css代码。

关于路由懒加载的介绍可参考:Vue Webpack 打包优化——路由懒加载(按需加载)原理讲解及使用方法说明- 掘金

缓存静态资源

使用缓存能直接避免加载资源,直接使用缓存中的资源

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

给script标签添加defer或者async属性

给script标签添加defer或者async属性,能让加载js文件的时候,不阻塞dom树的构建

压缩等js,css,html等静态资源的大小

压缩静态资源的大小能减少资源的加载时间

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

内联首屏关键js,css

内联首屏关键css或者js文件,这样首屏关键css和js就会随着html文件的下载而被下载,不但能减少请求的次数,还能提高首屏的渲染速度。

从http请求优化

减少http请求的次数,能缩短资源的加载时间

  • 将多个体积较小的css或者js文件,合并为单个文件(如 bundle.css),减少请求次数。
  • 使用雪碧图(或者说精灵图),减少请求小图片的次数

使用服务端渲染SSR

使用服务端渲染,可以省去前端拼接html结构的工作

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

对于vue,推荐使用nuxt.js

如何提高SPA的SEO

首先我们思考一个问题,为什么需要提高SPA的seo?

  • 传统web开发,一般就是多页面应用程序,每个页面的html结构都在服务端拼接好,所以没有SEO问题。
  • SPA的页面内容通过 Js 动态渲染,初始 HTML 通常是空壳(如 <div id="root"></div>),真实内容由 JS 后续填充。
  • 传统搜索引擎爬虫(如早期 Googlebot)可能无法执行 JavaScript,导致只能抓取空白页面。

那如何解决SPA的SEO问题呢?答案是使用服务端渲染

服务端渲染(SSR),指由服务端完成页面的 HTML结构拼接的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。使用服务端渲染,返回的页面,就已经包含了一定的页面结构,能够被搜索引擎爬虫爬取

除了能提高SPA的SEO,使用服务端渲染还能提高首屏的加载速度,因为不需要浏览器执行js来拼接html。

简单实现的代码如下:

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中的资源路径部分

优缺点

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

  • 非常适合用来做服务端渲染,从而提高页面的SEO

    • 使用History 路由的项目,前端路由混合在url的资源路径部分
    • 这意味着前端路由能发送到后端服务器,后端服务器能为每一个前端路由生成对应的完整的html文件。
    • 使用了ssr的History 路由项目,搜索引擎爬取每一个前端页面都能得到完整的html结构,从而提高了项目的seo。
  • 需要后端支持,否则会出现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(state, title, url)

  • 功能

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

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

  • 参数

    • state: 一个对象,用于存储与该状态相关联的数据,可以通过 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(state, 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,浏览器会认为这是一个全新的资源而重新下载它,而不是使用缓存,这是也是打包工具会帮忙做的事情。

http请求优化

减少http请求的次数

  • 将多个体积较小的css或者js文件,合并为单个文件(如 bundle.css),减少请求次数。
  • 使用雪碧图(或者说精灵图),减少请求小图片的次数
  • 内联首屏关键css或者js文件,这样首屏关键css和js就会随着html文件的下载而被下载,不但能减少请求的次数,还能提高首屏的渲染速度。

原理

  • 每个请求和响应都包含头部信息,虽然这些头部信息通常很小,但在大量小文件的情况下,累积起来也会影响性能。
  • 浏览器对同一域名下的并发请求数有限制(通常是6个),这意味着如果同时有超过这个数量的请求,则需要排队等待,进一步增加了加载时间。

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对象,则返回一个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算法

是什么

diff 算法是一种在同层的树节点,进行比较的高效算法

其有两个特点:

  • 比较只会在同层级进行, 不会跨层级比较
  • 在diff比较的过程中,从两边向中间循环比较

在vue中,我们使用diff算法来进行新旧虚拟dom的比较。

简化后的虚拟节点(vnode)大致包含以下属性:

1
2
3
4
5
6
7
8
{
tag: 'div', // 标签名
data: {}, // 属性数据,包括class、style、event、props、attrs等
children: [], // 子节点数组,也是vnode结构
text: undefined, // 文本
elm: undefined, // 真实dom
key: undefined // 节点标识
}

文本结点的tag为undefined,children也是空数组,text的值是文本内容

diff过程

具体过程:当在模板中使用过的响应式数据改变->触发setter->触发dep.notify->通知所有订阅者更新,其中就包括渲染Watcher->调用渲染Watcher的update方法,将渲染Watcher放到异步更新队列->清空异步更新队列,调用渲染Watcher的run方法->调用render方法产生新的VNode->调用patch方法->调用isSameVnode判断是否可以复用dom->如果可以则调用patchVnode方法->给DOM打补丁。

patch

  • 调用patch方法,传入新旧虚拟结点(oldVnode, vnode)

  • 没有新节点(vnode=undefined),说明旧的结点该被删除了,直接触发旧节点的destory钩子,移除旧的dom;

  • 没有旧节点(oldVnode=undefined),说明是页面刚开始初始化的时候,此时,根本不需要比较了,直接使用新的vnode来创建dom,所以要只调用 createElm,将新的vnode转化成真实dom。

  • 如果oldVnode, vnode都存在,则调用sameVNode方法,从key,tag等方面判断是否属于同一结点,如果返回true,表明结点可复用,则进一步调用patchVNode方法,给dom打补丁;

  • 如果sameVNode返回false,说明旧的结点不可复用,则删除旧的dom,直接使用新的vnode创建新的dom。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//简化后的patch
function patch (oldVnode, vnode) {
var elm, parent;
if (sameVnode(oldVnode, vnode)) {
// 相似就去打补丁(增删改)
patchVnode(oldVnode, vnode);
} else {
// 不相似就整个覆盖,创建新的dom,移除旧的dom
elm = oldVnode.elm;//获取旧的dom
parent = api.parentNode(elm);//找到旧的dom的父元素
createElm(vnode);//使用新的vnode创建dom,创建的dom可通过vnode.elm拿到
if (parent !== null) {
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));//插入新的dom
removeVnodes(parent, [oldVnode], 0, 0);//移除旧的dom
}
}
return vnode.elm;
}
1
2
function sameVnode (a, b) {
return a.key === b.key && a.tag === b.tag;

patchVNode

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
function patchVnode (oldVnode, vnode) {
//如果新旧虚拟结点完全一样直接返回
if(oldVnode === vnode){
return
}
// 判断是否是组件 VNode
if (isRealComponent(vnode)) {
// 组件 VNode 有自己的一套 prepatch 流程
const prevComponent = oldVnode.componentInstance;
const hook = vnode.data?.hook;
if (hook && hook.prepatch) {
//这是 Vue 内部定义的一个钩子函数,用于处理组件更新逻辑
hook.prepatch(oldVnode, vnode); // 在这里调用 updateChildComponent
}
return;
}

// 新节点引用旧节点的dom,此时新的虚拟结点也有自己的dom了,不过还需要打补丁
let elm = vnode.elm = oldVnode.elm;
// 获取新结点的子元素
const oldCh = oldVnode.children
// 获取旧结点的子元素
const ch = vnode.children;

// 调用update钩子,更新dom的class,style,props
if (vnode.data) {
updateAttrs(oldVnode, vnode);
updateClass(oldVnode, vnode);
updateEventListeners(oldVnode, vnode);
updateProps(oldVnode, vnode);
updateStyle(oldVnode, vnode);
}
//开始给子元素打补丁
if (vnode.text == undefined) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
//if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
api.setTextContent(elm, vnode.text)
}
}
  • 进一步比较oldVnode, vnode,如果oldVnode === vnode,也就是说新旧虚拟结点完全相同,则直接return,什么也不做。

  • 如果新旧虚拟结点不同,拿到对应的真实dom(旧的dom),称为elm,其实就是oldVnode的elm属性,并将oldVnode.elm属性赋值给vnode.elm

  • 先对dom的属性打补丁,对属性打补丁其实就是调用各种updateXXX()函数,更新真实dom的各个属性。每个的update函数都类似,就拿updateAttrs()举例看看:

    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
    function updateAttrs (oldVnode, vnode) {
    let key, cur, old
    const elm = vnode.elm
    const oldAttrs = oldVnode.data.attrs || {}
    const attrs = vnode.data.attrs || {}

    // 更新/添加属性
    for (key in attrs) {
    cur = attrs[key]
    old = oldAttrs[key]
    if (old !== cur) {
    if (booleanAttrsDict[key] && cur == null) {
    elm.removeAttribute(key)
    } else {
    elm.setAttribute(key, cur)
    }
    }
    }
    // 删除新节点不存在的属性
    for (key in oldAttrs) {
    if (!(key in attrs)) {
    elm.removeAttribute(key)
    }
    }
    }

    总结一下上述代码主要流程:遍历vnode(新的vnode)属性,如果和oldVnode不一样,就调用setAttribute()修改;遍历oldVnode属性,如果不在vnode属性中,就调用removeAttribute()删除,确保新的dom属性和vnode属性相同

  • 再对子元素打补丁:新节点是否是文本节点(文本结点没有子节点),如果是(那么旧结点也是文本结点,否则sameVNode不会返回true,不会执行patchVNode),则直接更新旧的dom的文本内容为新节点的文本内容

  • 只有新节点有子节点,旧节点没有,那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新DOM,并且添加进父节点

  • 只有旧节点有子节点而新节点没有,说明更新后的页面,旧节点的子节点全部都不见了,那么要做的,就是把所有的旧节点的所有子节点删除,也就是直接把DOM 删除。

  • 如果oldVnode, vnode都有子节点,且不完全一致,则调用updateChildren,进行子节点之间的比较,这涉及到diff算法的核心部分。

updateChildren

在diff比较子节点的过程中,从两边向中间循环比较,在这个循环比较过程中,又会调用sameVNodepatchNode方法,然后patchNode又会调用updateChildren方法。

  • 只在同级别对比,减少比较次数,提高比对性能(sameVnode方法就是同级别比较)
  • 首尾指针法:给新旧虚拟结点的子元素数组,都添加首尾指针
    • 进行头头,尾尾,头尾,尾头结点比较,判断是否是sameVNode
    • 当任意一个头指针大于它的尾指针,退出循环
    • 循环结束时,删除/添加多余dom
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
function updateChildren (parentElm, oldCh, newCh) {//传入的参数是新旧虚拟子节点数组
//首尾指针
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1

let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]

let oldKeyToIdx, idxInOld, elmToMove, before
//当任意一个头指针大于它的尾指针,退出循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // 未定义表示被移动过
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) { // 头头相似
//进行一次patch,然后newStartVnode就能拿到对应的dom了,虽然是旧的,还需要打补丁
patchVnode(oldStartVnode, newStartVnode)
//指针移动,修改oldStartVnode,newStartVnode
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) { // 尾尾相似
patchVnode(oldEndVnode, newEndVnode)//同理,newEndVnode就能拿到对应的dom了,虽然是旧的,还需要打补丁
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // 头尾相似
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // 尾头相似
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 根据旧子节点的key,生成map映射
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 在旧子节点数组中,找到和newStartVnode相似节点的下标
idxInOld = oldKeyToIdx[newStartVnode.key]
if (isUndef(idxInOld)) {
// 没有key,创建并插入dom
api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
} else {
// 有key,找到对应dom ,移动该dom并在oldCh中置为undefined
elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = undefined
api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm)
newStartVnode = newCh[++newStartIdx]
}
}
}
// 循环结束时,删除/添加多余dom
if (oldStartIdx > oldEndIdx) {
before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}

参考资料:

具体复用方式

  • 在大多数情况下,一个 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(比如localhost:8080->localhost:3000)
    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框架为例)

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),简单的来说404页面需要前端来设计

直接打开页面空白问题

直接打开页面,页面空白本质就是因为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://协议的跨域闲置比较宽松。

Vue2与Vue3有什么不同

响应式实现方法不同

  • vue2的响应式是基于Object.defineProperty实现的,只能监听已有属性的getter和setter,当删除一个属性或者添加一个属性的时候,vue无法监听到。为了解决这个问题,vue2中提供了this.$set(),this.$delete 这2个API。this.$set(obj,key,value)的本质就是使用Object.defineProperty响应式地为obj添加一个属性key,并通知这个对象(obj)的订阅者更新(因为vue为每一个对象都创建了一个dep)。this.$delete本质应该就是删除一个属性,然后通知这个对象(obj)的订阅者更新。除此之外还重写了数组的原型方法,如pushpopsplice等,使得这些方法在被调用时能够触发视图的更新。

  • 而在vue3中,响应式是基于Proxy实现的。new Proxy并传入一个源对象,返回一个代理对象,修改这个代理对象会映射到源对象。使用Proxy监听的是整个对象,而不是对象的属性,所以能监听到对对象的增删改查。

  • Vue2通过Object.defineProperty()进行数据劫持,需要遍历所有属性,有一定性能开销。Vue3使用Proxy代理数据,直接监听对象,减少遍历属性时间,而且是按需添加响应式的,性能更好。

API风格不同

在vue2使用的是选项式api,在vue3中既可以使用组合式api,又可以使用选项式api。

其他

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

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

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
8
9
const state = reactive({
bar: { a: 1 }
})

//最终结果- 输出:"获取bar:[object Object]"
//因为 state.bar 就是在访问bar属性,触发getter,所以输出:"获取bar:[object Object]",并且返回{a:1}
//然后访问{a:1}的a属性,由于{a:1}是个普通对象,所以不会触发对应的getter
//简单的来说,这个操作进行了两次属性访问,但是只触发了一次getter
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

由此可以看出,Proxy是按需添加响应式的,只有当我们取出的值是一个对象的时候,Proxy才会递归给这个对象添加响应式。而在Object.defineProperty中是直接递归添加响应式的。

总结

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

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

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

说说Vue 3.0中Treeshaking特性?

是什么

Tree shaking 是一种通过清除多余js代码方式,来优化项目打包体积的技术。Vue3源码实现了高度的ESM模块化,更好的支持Tree-shaking。

如何做

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做了哪些优化?》