Vue2
推荐一个学习vue的网站:Vue3
说说你对vue的理解
前端发展背景
最早的网页是没有数据库的,可以理解成就是一张可以在网络上浏览的报纸,就是纯静态页面
直到CGI
技术的出现,通过 CGI Perl 运行一小段代码,与数据库或文件系统进行交互
(前后端交互)
后来JSP(Java Server Pages)技术取代了CGI技术,其实就是Java + HTML
1 | <%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%> |
JSP有一个很大的缺点
,就是不太灵活。JSP使用 Java
而不是 JavaScript
,并且 Java 代码只能在服务器端运行。我们每次的请求:获取的数据、内容的加载,服务器都会做对应的处理,并渲染dom然后返回渲染好的dom,简单的来说,JSP把页面的渲染工作完全交给后端服务器。
后来ajax
火了,它允许用户在不刷新整个页面的前提下,和后端服务器交换数据,并由浏览器执行js代码,更新部分页面。
随后移动设备的普及,Jquery的出现,以及SPA(Single Page Application 单页面应用)的雏形,Backbone EmberJS AngularJS 这样一批前端框架随之出现,但当时SPA的路不好走,例如SEO问题,SPA 过多的页面、复杂场景下 View 的绑定等,都没有很好的处理。
经过这几年的飞速发展,节约了开发人员大量的精力、降低了开发者和开发过程的门槛,极大提升了开发效率和迭代速度。我们可以看到Web技术的变化之大与快,每一种新的技术出现都是一些特定场景的解决方案,那我们今天的主角Vue又是为了解决什么呢?
Vue是什么
是一个用于创建用户界面
的开源JavaScript框架
,也是一个创建单页应用(SPA)
的前端框架。
Vue核心特性
数据驱动视图更新
数据驱动(MVVM),相比于react,开发者无需手动调用
setState
来提示视图更新。MVVM表示的是 Model-View-ViewModel
- Model:模型层,负责处理业务逻辑以及和服务器端进行交互
- View:视图层:负责将数据模型转化为UI展示出来,可以简单的理解为HTML页面
- ViewModel:视图模型层,用来连接Model和View,是Model和View之间的通信桥梁,在vue中这个桥梁是vue实例
组件化
降低了代码的耦合度,可维护性,可扩展性高,便于调试。vue中的组件可分为单文件组件和多文件组件,vue中的组件是能实现部分功能的css,js,html等代码和资源的集合。
指令系统
指令 (Directives) 是带有
v- 前缀
的特殊属性
,当表达式的值改变时,将其产生的连带影响,响应式地作用于 DOM。简单的来说,vue中的指令系统简化了dom操作,而react中没有指令系统。
Vue的学习路线
原生开发
通过script标签引入vue.js,src属性通常是http链接,或者下载到本地的vue.js文件的路径。
1 | <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> |
如果是http链接,当浏览器加载这个脚本,会发送一个get请求获取并执行vue的js代码,类似jsonp请求。
引入vue.js后,Vue这个构造函数成为全局变量,挂载到window对象上
然后我们在页面的script标签中写些代码,创建一个vue实例,传入一个配置对象
1 | const app = new Vue({ |
此时我们还未引入组件的概念,但是我们已经能够学习vue的大部分知识点了。包括模板语法,数据绑定,数据代理如何实现,vue的常用指令,计算属性,数据监听,vue的生命周期等等。
原生开发之组件化开发
什么组件?组件化开发有什么好处?
在vue中,组件就是能实现局部功能
的html,css,js
代码的集合,组件化开发有利于代码复用,提高开发效率,同时把功能上密切相关的html,css,js代码放到一起,依赖关系明确,易于维护。
vue的组件可分为单文件组件和非单文件组件,非单文件组件就是通过Vue.extend({}),返回一个VueComponent构造函数,这个
构造函数被用来创建组件实例,依赖的配置对象就是Vue.extend({})
传入的对象,这个配置对象的结构和new Vue()
传入的配置对象的结构几乎一致。存在如下关系,即Vuecomponent
是Vue的子类
。
1 | 组件实例._proto_ = VueComponent.prototype |

非单文件组件使用
1 | <div id="app" :name="str"> |
1 | //定义一个school组件 |
单文件组件
单文件组件就是我们熟知的.vue
文件, 单文件组件解决了非单文件组件无法复用css代码
的问题,我们开发过程中使用的最多的组件也是单文件组件。
显然,.vue
文件是vue团队开发的文件,无法在浏览器上运行,所以我们需要借助打包工具webpack来处理这个文件,webpack又是基于nodejs
的,nodejs
是使用模块化开发的。这样vue的开发就过渡到了基于nodejs+webpack的模块化开发,为了简化模块化开发过程中webpack的配置,vue团队就开发了vue-cli
,即vue的脚手架
单文件组件的大致结构如下:
1 | <template> |
其中export default {}
由export default Vue.extend({})
简化而来的,组件注册的时候会自动处理:如果发现注册的组件是一个对象,则使用Vue.extend
包裹,否则直接注册。
组件之间通过嵌套确定层级关系,所有其他组件都在根组件App.vue内,根组件直接嵌入index.html
文件;
组件化开发后不需要直接在html页面中写结构,内容被分解为一个一个vue组件。
谈谈对el和template属性的理解
当我们在学习Vue的基础语法,vue的组件的时候一定涉及到了这两个容易混淆的属性。
创建Vue根实例必须要给出el属性,指明要为哪个容器服务,这个容器会成为模板;创建
组件实例
不能传入el属性,简单的来说,el属性是Vue根实例独有的。如果创建vue根实例同时配置了el和template属性,则template将替换el指定的容器成为模板(可以参考
vm.$mount
源码,template属性优先级更高),不过要注意的是nodejs开发环境中,通过import
导入的vue
是精简版的,没有模板解析器的, 模板解析器被单独提取出来,作为一个开发环境的包(生产环境打包就不会将模板解析器包含进去,从而减小最终文件的体积),用来处理.vue
文件中的template
,所以在创建vue根实例的时候不能使用template
,所以无法借助它实现在页面中自动插入Vue.app的效果。1
2
3
4
5
6
7import App from './App.vue'
import Vue from 'vue'
new Vue({
el:'#root',
template:'<App></App>',
components:{App}
})上述代码会报错,不能配置
template
应当修改为:
1
2
3
4
5
6import App from './App.vue'
import Vue from 'vue'
new Vue({
el:'#root',
render:h => h(App)//传入的h是createElement函数,用来创建VNode
})或者引入完整版的vue.js
1
import Vue from 'vue/dist/vue.js'
创建组件必须指定组件的结构,即template,组件的模板,不必指定组件为哪个容器服务(不要el)
el指定的容器中的
结构
可以被抽离为一个一个单独的模板template
,一个个单独的组件,也就是说模板中可以不写实际结构,只写组件标签,这些组件标签会在模板解析的时候被解析。其实组件中的
template
也能被拆分,从而形成一个一个组件,这就是组件的嵌套。
说说Vue的生命周期
vue的生命周期指的是vue实例从创建到销毁的过程,可分为vue实例初始化前后,dom挂载前后,数据更新前后,vue实例销毁前后四个阶段。这四个阶段分别对应了8
个生命周期函数。
生命周期函数指的是在vue实例特定时间段执行的函数。
这里拿vue2的生命周期函数举例。
- beforeCreate:vue实例刚被创建,能拿到this,部分初始化工作完成,但是数据代理还未开始(未调用
initState
方法),此时无法通过this获取data和methods等 - created: 此时几乎所有配置属性比如inject,data,method,computed,props,watch,provide都初始化完成,但是模板解析(是为了得到render函数,render函数是用来创建虚拟dom的)还未开始(未调用
vm.$mount
方法),页面展示的是未经vue编译的dom。 - beforeMount:template模板解析结束,render函数创建完毕,但是虚拟dom还未转化成真实dom挂载到页面中,此时展示的还是旧的页面(未经编译的页面)
- mounted:把初始的真实DOM放入页面,此时对dom的操作是有效的。
- beforeUpdate:此时数据是新的,页面展示的内容是旧的,因为vue视图是异步更新的。
- updated: 此时
新旧虚拟dom比较
完毕,页面已更新。 - beforeDestroy:当执行beforeDestroy的钩子的时候,Vue实例就已经从运行阶段进入销毁阶段,但身上所有的data和methods,以及过滤器、指令等,都处于可用状态,还未真正执行销毁的过程
- destroyed: 完全销毁一个实例。可清理它与其它实例的连接,解绑它的全部指令及事件监听器;并不能清除DOM,仅仅销毁实例。所以页面并不会改变,但是变得无法响应交互。

数据请求在created和mouted的区别
- 这两个阶段
数据
和方法
都已经初始化,都能通过this
访问到,因为created
的执行时期更早,所以能更早的发送请求,更快的返回数据。 - 一个组件中有子组件,它们的生命周期函数的执行顺序是先执行父组件的前三个声明周期函数,再执行子组件的前四个生命周期函数,然后在执行哦父组件的
mouted
函数。
对于vue3中的生命周期的介绍,参考《vue》一文。
说说你对vue双向绑定的理解
双向绑定
不等同于响应式
了,这两个东西是有区别的。
响应式
当更改响应式数据
时,视图会随即自动更新
,那在vue中是如何实现数据的响应式的呢?其实主要就分为2部分,给数据添加响应式和依赖收集
new Vue()
首先执行初始化,对data
执行响应化处理,这个过程发生constructor->_init->initState->initData->observe
中- 同时对模板执行编译,找到其中动态绑定的数据,从
data
中获取并初始化视图,这个过程发生在Compile
中 - 同时定义⼀个更新函数和
Watcher
,将来对应数据变化时,Watcher
会调用更新函数 - 由于
data
的某个key
在⼀个视图中可能出现多次,所以每个key
都需要⼀个管家dep
来管理多个Watcher
- 将来
data
中数据⼀旦发生变化,会首先找到对应的dep
,通知所有Watcher
执行更新函数
具体代码参考《说说Vue实例挂载过程中发生了什么》其中有详细的介绍。
双向绑定
双向绑定,是数据变化驱动视图更新,视图更新触发数据变化。其实就是v-model
的功能,而我们知道v-model
只是一个语法糖。因此如果要问双向绑定的原理,思路应该是如何实现这个语法糖。其原理是把input
的value绑定data的一个值,当原生input的事件触发时,用事件的值来更新data的值。
1 | <!-- 使用 v-model --> |
说说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 | //源码位置:src\core\instance\index.js |
options
是用户传递入的配置对象,包含data、methods
等常用属性。
vue
构建函数调用了_init
方法,并传入了options
,所以我们关注的核心就是_init
方法:
_init

1 | //位置:src\core\instance\init.js |
分析后得出如下结论:
- 在调用
beforeCreate
之前,主要做一些数据初始化的工作,数据初始化
并未完成,像data
、props
这些对象内部属性无法通过this
访问到。所以说beforeCreate
的执行时机先于data()函数调用
,data函数调用,是在初始化data的时候被触发的。 - 执行
created
的时候,数据已经初始化完成,能够通过this
访问data
、props
这些对象的属性,但这时候并未完成dom
的挂载,因此无法访问到dom
元素 - 通过调用
vm.$mount
方法实现了dom挂载
我们先主要分析initState
方法
initState

1 | //源码位置:src\core\instance\state.js |
分析后发现,initState
方法依次,统一初始化了props/methods/data/computed/watch
,说明在created
的时候,这些东西都准备好了,或者说初始化工作都完成了。
我们继续分析initState
中的initData
方法,关于initProps
和initComputed
等其他属性的初始化做了什么,这里暂时不深入研究。
initData
1 | function initData (vm: Component) { |
阅读源码后发现:
props
和method
在data
之前就被初始化了,所以data
中的属性值不能与props
和methods
中的属性值重复
;之所以要防止重复,因为它们都会被代理到this(vm)
上(是的,包括props中的数据),都是直接通过this
来访问,重复了就会产生冲突。同时我们也可以发现,props
中的数据的优先级是高于data
中的数据的,因为初始化的时机更早。data
定义的时候可选择函数形式
或者对象形式
(组件只能为函数形式),data()
函数调用是为了产出数据,挂载到vm._data
上,然后再给数据添加代理
,添加响应式
,所以data
函数被调用的时候,内部是无法通过this
来访问其他数据的。initData
方法把vm._data
中的属性代理到vm
上并给vm._data
上的数据添加了响应式
(实现了数据的代理,给数据添了响应式)。
vue的数据代理核心在于proxy
方法,我们来看看它做了什么
proxy方法
1 | function proxy(target, sourceKey, key) { |
再次之后,访问target.key
返回的就是target.sourceKey.key
,说到底还是从target上面取数据,只不过简化了访问的路径。
vue给数据添加响应式的核心在于observe
方法,我们来分析一下这个方法
observe方法源码
1 | function observe(obj) { |
我们很容易发现这个类的核心是defineReactive
方法,那么这个方法内部到底做了些什么呢?其实它主要就做了2个工作:劫持属性和收集属性的依赖。
1 | const obj = { name: 'tom', age: 22 } |
1 | class Dep { |
1 | // 负责更新视图 |
可以看到:
defineReactive
方法的核心在于使用Object.defineProperty
给对象属性添加监听
,且没有借助其他源对象。而是闭包中的数据。如果
val
的值是对象,则递归添加响应式触发get要收集依赖,为每个key都创建一个Dep实例,来管理这个key的所有依赖(Watcher)
触发set的时候,调用
dep.notify()
,通知这个key的所有依赖更新每个Watcher实例的三大属性:
vm,key,updateFn
,创建Watcher实例是在模板编译的时候进行的。修改后的Obj对象结构如图,可以看到原来的属性被
覆盖
了,变得不可枚举。

补充:Watcher类别
渲染 Watcher(Render Watcher)
作用:负责组件的视图更新,每个组件对应一个渲染 Watcher。
run方法行为:执行getter函数(即updateComponent),触发
_render()
生成新虚拟DOM,调用_update()
对比新旧虚拟 DOM(Diff 算法),最终更新真实 DOM结论:渲染 Watcher 的
run
方法一定会触发render
方法。
计算属性 Watcher(Computed Watcher)
作用:追踪计算属性的依赖,缓存计算结果
run方法行为:重新计算计算属性的值,但不会触发
render
方法,除非计算结果变化且被模板引用
用户自定义 Watcher(通过
watch
选项)作用:监听数据变化并执行回调函数
run方法行为:执行用户定义的回调函数(如
handler
),不会触发render
方法
总结
分析之后我们发现,vue2中的数据代理
和数据监听
都是通过Object.defineProperty
实现的。
initProps
1 | //传入的第二个参数是子组件中的props属性的值(props配置对象) |
当父数据是响应式时,子组件通过依赖收集
成为订阅者,父数据变化自动触发子组件更新。
vue的构造函数中使用的挂载方法是vm.$mount
,我们尝试分析它的源码:
vm.$mount

1 | Vue.prototype.$mount = function (el?: string | Element,hydrating?: boolean): Component { |
阅读上面代码,我们能得到以下结论:
根元素不能是
body
或者html
,也就是说el
的指向不能是这两个元素。$mount
方法的工作流程就是:
如果有
render
函数则直接调用mountComponent
方法如果没有
render
函数,则尝试生成这个函数:如果有
template
属性,再考虑这个属性的值,我们期望是一个html字符串,但实际还可能是dom对象,我们把它转化成html字符串如果既没有
render
也没有template
,那就必须有el
,通过getOuterHTML(el)
得到的值来代替template
无论如何,最终
template
属性的值是一个html字符串解析,编译模板字符串,得到
render
函数,挂载到options
上
生成render
函数,挂载到options
上后,再调用mountComponent
开始开始渲染
mountComponent
1 | export function mountComponent ( |
方法主要执行在vue
初始化时声明的_render
,_update
方法,_render
的作用主要是生成vnode
_render
方法内部其实使用的是我们模板解析后得到的render
函数,最终返回一个vnode。
_render方法源码
1 | Vue.prototype._render = function (): VNode { |
_update
主要功能是调用patch
,将vnode
转换为真实DOM
,并且更新到页面中。
手写一个简单的Vue
了解了Vue实例的挂载过程后,我们应该就能够模拟实现一个简单的Vue。
1 | <body> |
模板解析
1 | class Vue{ |
生命周期
vue2的生命周期函数很好实现,无非就是在特定时期调用特定的函数。
1 | class Vue{ |
添加事件
1 | class Vue{ |
添加代理
1 | class Vue{ |
添加响应式

至此,我们修改数据,视图显然是不会更新的,即没有实现数据驱动视图更新的效果
,简单来说,就是没有实现响应式。
想要实现响应式,我们需要监听数据,并收集依赖
所谓收集依赖,就是要知道data
中的某个属性,到底在哪些文本结点(或者计算属性)中使用过了,换句话说,就是这些文本结点依赖data中的那个数据;这个数据改变时,我们需要监听到这个数据的变化,然后通知依赖这个数据的文本结点(或者计算属性)更新内容。
依赖收集主要是在模板解析
过程中进行的,在监听到数据的getter
被触发的时候,收集它的依赖。
我们定义一个Watcher
类来记录文本结点
,文本结点内部未编译前的字符串
,和它依赖的数据(vm.key)
。
1 | class Watcher{ |
1 | class Vue{ |
双向绑定
双向绑定
并不等同于响应式
了,这两个东西是有区别的。
响应性
简单来讲就是当更改响应式数据时,视图会随即自动更新,即数据驱动视图更新
。而实现这个功能的原理就是劫持(监听)数据
,收集依赖
,当数据发生变化时,执行相应的依赖(副作用/更新视图)。
双向绑定
是数据变化驱动视图更新,视图更新触发数据变化。其实就是v-model
的功能,而我们知道v-model
只是一个语法糖。因此如果要问双向绑定
的原理,思路应该是如何实现这个语法糖
。
只需完善$compile
方法和update
方法。
完整代码
1 | class Watcher { |
Vue.observable你有了解过吗?说说看
Vue.observable
,让一个对象变成响应式数据。Vue
内部会用它来处理 data
函数返回的对象
在 Vue 2.x
中,被传入的对象会直接被 Vue.observable
变更,它和被返回的对象是同一个对象,不过在原来的基础上添加了响应式,这一点,看看前面对defineReactive
方法的介绍就很容易理解了。
在 Vue 3.x
中,则会返回一个可响应的代理对象,而对源对象直接进行变更仍然是不可响应的,因为在vue3中响应式的实现是基于Proxy
这个构造函数,传入一个对象,会返回一个新的
代理对象,对代理对象的修改会映射到源对象。
使用场景
创建一个js
文件
1 | // 引入vue |
在.vue
文件中直接使用即可
1 | <template> |
详细解释
依赖收集
是组件初始化过程中,模板解析时候的工作,组件模板解析的时候,如果使用到了某个响应式对象
的某个属性,就会new
一个watcher
,存储到Dep.target
中,然后取值的时候会触发getter
,getter
内部会判断Dep.target
是否为空,不为空则收集依赖,把这个watcher
取出来,push
到这个属性(key)的deps
(依赖数组)中。然后某个属性值修改的时候就会触发对应的setter
,通知这些依赖的watcher
更新内容,即调用依赖这个属性(key)的watcher
的update
方法。
所以说Vue.observable
只能给数据添加响应式,但是想要实现数据修改,依赖这些数据的组件也重新渲染,就需要在模板解析过程中收集依赖。
说说你对slot的理解?slot使用场景有哪些?
slot
的作用就是用来自定义组件内部的结构
slot
可以分来以下三种:
- 默认插槽
- 具名插槽
- 作用域插槽
默认插槽
子组件用<slot>
标签,来确定渲染的位置,标签里面可以放DOM
结构,当父组件没有往插槽传入内容,标签内DOM
结构,就会显示在页面
父组件在使用的时候,直接在子组件的标签内写入内容即可
子组件Child.vue
,使用slot标签占位,标签体内的结构是默认结构
1 | <template> |
父组件向子组件传递结构,只需要在子组件标签体内写结构就好了
1 | <Child> |
父组件给子组件传入的自定义结构,可以在子组件的this.$slots
属性中拿到。

具名插槽
默认插槽形如
1 | <slot> |
当我们给slot
标签添加name
属性,默认插槽就变成了具名插槽
当我们需要在子组件内部的多个位置使用插槽的时候,为了把各个插槽区别开,就需要给每个插槽取名。
同时父组件传入自定义结构的时候,也要指明是传递给哪个插槽的,形象的来说,就是子组件挖了多个坑,然后父组件来这些填坑,需要把具体的结构填到具体的哪个坑。
子组件Child.vue
1 | <template> |
父组件
1 | <child> |
template
标签是用来分割,包裹自定义结构的。v-slot
属性用来指定这部分结构用来替换哪个插槽,所以v-slot
指令是放在template标签上的,要注意的是如果想要将某部分结构传递给指定的插槽,因该使用v-slot:xxx
,而不是v-slot='xxx'
v-slot:default
可以简化为#default
,v-slot:content
可以简化成#content
作用域插槽
子组件在slot
标签上绑定属性,来将子组件的信息传给父组件使用,所有绑定的属性(除了name属性),都会被收集成一个对象,被父组件的v-slot
属性接收。
子组件Child.vue
1 | <template> |
父组件
1 | <child> |
可以通过解构获取v-slot={user}
,还可以重命名v-slot="{user: newName}"
和定义默认值v-slot="{user = '默认值'}"
所在slot
中也存在’’双向数据传递’’,父组件给子组件传递页面结构
,子组件给父组件传递子组件的数据。
你有写过自定义指令吗?自定义指令的应用场景有哪些?
什么是指令
在vue
中提供了一套为数据驱动视图
更为方便的操作,这些操作被称为指令系统
。简单的来说,指令系统
能够简化dom操作,帮助方便的实现数据驱动视图更新
。
我们看到的v-
开头的行内属性,都是指令,不同的指令可以完成或实现不同的功能
除了核心功能默认内置的指令
(v-model
和 v-show
),Vue
也允许注册自定义指令
指令使用的几种方式:
1 | //会实例化一个指令,但这个指令没有参数 |
注意:指令中传入的都是表达式,无论是不是自定义指令,比如
v-bind:name = 'tom'
,传入的是tom这个变量的值,而不是tom字符串,除非写成"'tom'"
,传入的才是字符串。
如何实现
关于自定义指令,我们关心的就是三大方面,自定义指令的定义,自定义指令的注册,自定义指令的使用。
自定义指令
的使用方式和内置指令
相同,我们不再研究,其中的难点就是定义自定义指令
部分。
注册自定义指令
注册一个自定义指令有全局注册
与局部注册
两种方式
全局注册主要是通过Vue.directive
方法进行注册
Vue.directive
第一个参数是指令的名字(不需要写上v-
前缀),第二个参数可以是对象数据,也可以是一个指令函数
1 | //全局注册一个自定义指令 `v-focus` |
局部注册通过在组件配置对象中设置directives
属性
1 | directives: { |
然后就可以使用
1 | <input v-focus /> |
在vue3中,局部注册的语法就不同了。如果混合使用选项式api
,就可以像vue2一样借助directives
属性解决,如果使用的是setup语法糖
写法,就需要遵守如下语法:
1 | <template> |
导入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
:指令绑定的前一个值,仅在update
和componentUpdated
钩子中可用。无论值是否改变都可用。expression
:字符串形式的指令表达式。例如v-my-directive="1 + 1"
中,表达式为"1 + 1"
,又比如v-for="(value, key, index) in obj"
,传入的表达式为"(value, key, index) in obj"
arg
:传给指令的参数,可选。例如v-my-directive:foo
中,参数为"foo"
,又比如v-bind:class = "['box']"
的参数为class
modifiers
:一个包含修饰符的对象。例如:v-my-directive.foo.bar
中,修饰符对象为{ foo: true, bar: true }
vnode
:Vue
编译生成的虚拟节点oldVnode
:上一个虚拟节点,仅在update
和componentUpdated
钩子中可用
应用场景
给某个元素添加节流
1 | // 1.设置v-throttle自定义指令 |
Vue常用的修饰符有哪些有什么应用场景
修饰符是什么
在Vue
中,修饰符是用来修饰Vue中的指令的,它处理了许多DOM
事件的细节,让我们不再需要花大量的时间去处理这些烦恼的事情,而能有更多的精力专注于程序的逻辑处理。
vue
中修饰符分为以下五种:
- 表单修饰符
- 事件修饰符
- 鼠标按键修饰符
- 键值修饰符
- v-bind修饰符
修饰符的具体作用
表单修饰符
在我们填写表单的时候用得最多的是input
标签,指令用得最多的是v-model
关于表单的修饰符有如下:
- lazy
- trim
- number
lazy
在我们填完信息,光标离开标签的时候,才会将值赋予给value
,也就是在change
事件之后再进行信息同步
1 | <input type="text" v-model.lazy="value"> |
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>
当监听
touchstart
、touchmove
或wheel
(滚动)等高频事件时,浏览器的默认行为是:等待事件处理函数执行完毕再决定是否执行默认行为(如滚动页面),如果事件处理函数中存在耗时操作(如复杂计算),会导致 滚动卡顿,因为浏览器必须等待函数执行完毕,才能滚动页面(默认行为)。
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 | MyPlugin.install = function (Vue, options) { |
注册形式
组件注册
vue
组件注册主要分为全局注册
与局部注册
局注册通过Vue.component
方法,第一个参数为组件的名称,第二个参数为传入的配置项
1 | Vue.component('my-component-name', { /* ... */ }) |
局部注册只需在用到的地方通过components
属性注册一个组件
1 | const component1 = {...}// 定义一个组件 |
在vue3中的组件注册:
全局注册:
1 | import { createApp } from 'vue'; |
局部注册:
1 | <script> |
或者
1 | <script setup> |
在 <script setup>
中导入的组件会自动注册并在模板中可用,无需显式地在 components
选项中列出它们。
插件注册
插件的注册通过Vue.use()
的方式进行注册,第一个参数为插件的名字
,第二个参数是可选择的配置项
1 | Vue.use(插件名字[,options]) |
注册插件的时候,需要在调用
new Vue()
启动应用之前
完成,Vue.use
会自动阻止多次注册相同插件,只会注册一次。
Vue组件通信的方式有哪些
vue
中,每个组件之间的都有独自的作用域
,组件间的数据是无法共享的,但实际开发工作中我们常常需要让组件之间共享数据,这也是组件通信的目的,要让它们互相之间能进行通讯,这样才能构成一个有机的完整系统。
组件间通信的分类
- 父子组件之间的通信
- 兄弟组件之间的通信
- 祖孙与后代组件之间的通信
- 非关系组件间之间的通信
组件间通信的方案
props传递数据
适用场景:父组件传递数据给子组件,即父子组件之间的通信
父组件通过给子组件标签添加属性,来传递值,子组件设置props
属性,接收父组件传递过来的参数,同时还能限制父组件传递过来的数据的类型,还能设置默认值。
1 | <Children name="jack" age=18 /> |
1 | //Children.vue |
注意:
- props中的数据是父组件的,子组件不能直接修改,遵循”谁的数据谁来维护”的原则。
- 子组件标签的所有属性中,未被子组件接收(props中未声明)的数据,也能在
this.$attr
,即组件实例的属性
中拿到,因为未被接受的属性,就会被当作组件自身的普通属性。
再问大家一个问题,为什么父组件中的数据更新,子组件中通过props
接收的数据也会随之改变?
1 | //子组件 |
1 | //父组件 |
父组件的模板,在模板编译的时候,会被解析成一个render
函数,这一点我们在前面已经介绍过了,在上述例子中,父组件的模板解析成render
函数大概是这样:
1 | function render(createElement) { |
可以看出,在父组件的模板render
函数中,访问了父组件实例的age
属性,赋值给子组件的props.age
,这个过程中触发age
属性的getter
,于是收集父组件自身的render
函数为依赖(渲染Watcher),然后子组件初始化的时候,会调用initProps
方法:
1 | //传入的第二个参数,是子组件中的props属性的值(props配置对象) |
可以看出,父组件传递了,且子组件通过props
接收的数据,会被存储在vm.$options.propsData
,然后子组件初始化的时候(调用initProps
的时候),会将通过props
接收的数据添,加响应式,并代理到vm
上,缩短访问路径。
上述例子中,父组件传递给子组件的值,只不过是this.age
,是一个普通数据类型,压根不是响应式数据,这种传递会导致响应式丢失,触发getter
的位置,也是在父组件渲染函数内,子组件压根就没被age
属性收集为依赖,后续是子组件自己把age
属性添加到vm_props
并添加响应式的(后续又代理到vm),既然在父组件的age
属性并没有收集子组件为依赖,为什么父组件更新age属性,子组件也能接收到最新的值呢?
因为父组件中的age
属性改变,会触发对应的setter
,然后通知依赖更新,其中的依赖就包括父组件渲染Watcher
,然后父组件渲染你Watcher
会调用run
方法,这个方法会调用render
函数,重新给子组件的props
赋值,后续子组件中this_props
存储的就是最新的值。
$emit 触发自定义事件
适用场景:子组件传递数据给父组件(父子组件通信)
子组件通过$emit
触发自定义事件,$emit
第一个参数为自定义的事件名,第二个参数为传递给父组件的数值
父组件在子组件上绑定事件监听,通过传入的回调函数
拿到子组件的传过来的值。
1 | //Children.vue |
1 | //Father.vue |
要注意的是,给组件添加的事件监听是自定义事件,因为组件标签不是原生标签,无法添加原生事件监听,也就没有原生事件对象,所以传递给回调函数的是子组件传递过来的值,而不是原生dom事件。
在vue2中,我们只要给父组件传递数据,并给对应的属性添加sync
修饰符,就能省去在给组件标签添加事件监听,书写回调逻辑,同步父组件数据的代码,在vue3中,这一功能则是通过v-mode
l实现的,更多介绍参考本博客内的《vue》一文。
ref
在 Vue 2 中,this.$refs
是一个对象,它包含了所有通过 ref
属性注册的 DOM 元素
或组件实例
。你可以使用 this.$refs
来直接访问这些dom元素或组件实例,从而进行操作,如获取DOM节点、调用子组件实例的方法,获取数据等。
注意:this.$refs
只能在父组件中,用来引用通过 ref
属性标记的子组件
或 DOM 元素
1 | <Children ref="foo" /> |
同时,子组件也可通过this.$parent
拿到父组件实例
EventBus(事件总线)
使用场景:兄弟组件传值
通过共同祖辈
$parent
或者$root
搭建通信兄弟组件
1
this.$parent.on('add',this.add)
另一个兄弟组件
1
this.$parent.emit('add')
本质就是要找到一个两个兄弟组件都能访问到的vue实例,在这个实例上注册事件监听,同时也在这个实例上触发事件,本质和props,emit是一样的。这个vue实例的作用好像连接这两个组件的管道,通过这个Vue实例来通行。
provide 与 inject
跨层级传递数据,传递方向是单向的,只能顶层向底层传递。
在祖先组件
定义provide
属性,返回
传递的值,在后代组件通过inject
接收祖先组件传递过来的值
1 | // 普通类型是响应式的复杂类型则不是,这和vue2数据响应式的实现方式(递归添加响应式)有关 |
1 | export default { |
Vuex
关于vuex的介绍,详见vue | 三叶的博客
SPA
什么是SPA,和MPA有什么区别?
SPA指的是只有一个页面的web应用程序,所有必要的代码(
HTML
、JavaScript
和CSS
)都通过单个页面的加载而被加载(这样首屏加载速度就很慢),或者根据需要(通常是为响应用户操作),动态装载适当的资源,并添加到页面,页面在任何时间点都不会重新加载,也不会将控制转移到其他页面。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
来实现的。开发过程中,一个页面对应一个或者多个组件
,在打包后,每个组件都会转化成对应的css
,js
代码,其中的js代码
不光包括业务逻辑,也负责修改dom,构建页面。如果使用
路由懒加载
,我们可以观察到,打包后的js,css文件数量
变多了,每个文件的体积也变小了,是因为使用懒加载的组件
都被打包成独立的
css,js文件了。这样,index.html
引入的的js
,css
文件的体积也会变小,因为只包含首屏组件
需要的js,css
代码。从静态资源入手,减少加载时间
缓存静态资源
对于已经请求过的资源,再次请求直接使用缓存。比如我们每天都要刷b站,可以观察到,B站的页面样式改变的频率是比较低的,如果我们每次登录b站,都要重新请求这些css样式文件,然后再解析渲染,就比较慢了,但是如果我们缓存这些css文件,下次就可以省去加载这些资源的时间,从而提高首屏加载速度。
再比如,对于首屏固定不变的图片,如果我们缓存了,下次也可以直接使用。
压缩图片等静态资源的大小
这一点是显而易见的,压缩静态资源的大小,我们加载这些资源的时间就变少了,从而提高了首屏加载速度。我在部署自己的博客前,也会先把将要上传的图片,样式表,js文件,html文件等静态资源统一压缩,再上传,以求提高首屏加载速度。在实际开发过程中,这个功能通常是由webpack等模块化打包工具自动实现的。
内联首屏关键css
关于这一点,可以参考《前端面试—css》中的css性能优化部分。本质就是省去加载首屏关键css的时间。
使用服务端渲染SSR
将首页的html结构的拼接工作交给后端服务器,关于服务端渲染的介绍参考后文。
对于vue,推荐使用
nuxt.js
如何提高SPA的SEO
服务端渲染SSR
指由服务端
完成页面的 HTML结构拼接
的页面处理技术,发送到浏览器,然后为其绑定状态与事件,成为完全可交互页面的过程。
这意味着我们需要和服务器打交道
传统web开发,一般就是多页面应用程序,每个页面的html结构都在服务端拼接好。
单页面应用程序(SPA)通过浏览器执行js代码来实现页面的html结构的替换,拼接。
有利于SPA的SEO
使用服务端渲染,返回的页面就已经包含了一定的页面结构,能够被搜索引擎爬取。
提高的首屏渲染速度
使用服务端渲染,将首屏结构
交给服务端来拼接,这样不必等待页面所有js
加载完成,就可以看到首屏视图。
简单实现的代码如下:
1 |
|
1 | //因为是在服务端运行的代码,所以使用的是cjs语法 |
hash路由和history路由的实现原理,二者有什么区别?

哈希路由
(Hash-based Routing)和 History 路由
(History API-based Routing)是前端路由的两种常见实现方式,它们用于在单页面应用程序 (SPA) 中模拟多页面体验,而无需重新加载整个页面。
hash路由
是什么
前端路由
被放到url
的hash
部分,即url中#
后面的部分。哈希值
改变也不会触发页面重新加载
,但是会产生历史记录。- 浏览器不会将
哈希值
发送到服务器,因此无论哈希值
如何变化,刷新页面,服务器只会返回同一个初始 HTML 文件。
优缺点
- 不需要服务器配置支持,因为哈希值不会被发送给服务器。
兼容性好
,几乎所有浏览器都支持哈希变化事件。- URL 中包含显眼的
#
符号,可能影响美观。 - 前端路由部分十分明确,方便部署,可以部署在服务器的
任何位置
。
如何做

可以直接设置 window.location.hash
属性来改变
URL 中的哈希部分,改变 window.location.hash
不会触发页面刷新
,但它会添加一个新的历史记录条目
。
前端 JavaScript 监听 hashchange
事件来检测
哈希的变化,并根据新的哈希值更新页面内容。
1 | class Router { |
history路由
是什么
使用标准的路径形式,例如 http://example.com/page1
,前端路由
被放到url
中的资源路径
部分
优缺点
没有显眼的
#
号,更为美观搜索引擎可以直接抓取完整 URL(如
/about
),有利于 SEO 优化。非常适合用来做服务端渲染,提高页面的SEO:History 路由的 URL 结构,与传统多页应用的URL结构一致,这意味着服务端可以为每个路由生成独立的html文件。
需要后端支持,否则会出现
404
问题,因为前端路由会被当作资源路径,发送到后端,而后端并未做对应配置。对较老版本的浏览器兼容性较差,因为history路由是基于在H5才提出的History API
要求
index.html
文件引用资源的路径必须使用绝对路径因为基于History API,我们可以改变URL但是不实现页面跳转,展示的始终是同一个index.html文件。
但是当我们改变路由后(比如从
http://localhost:3000
变成http://localhost:3000/it/about
),再手动刷新页面的时候,就会发送get
请求http://localhost:3000/it/about
到服务器(假设是开发服务器devServer),显然对于这个请求url,开发服务器找不到对应的资源,于是返回根目录(通常是public文件)下的
index.html
文件(歪打正着)但是其他资源就没有这么好运了,浏览器拿到这个页面进行解析渲染,然后加载页面中的资源,比如css文件,如果我们使用的是相对路径,最终请求这些资源的请求路径,还会与当前页面url拼接,所以当前页面的url是不确定的,而我们资源的位置肯定是固定的,所以很容易找不到对应的资源,所以开发服务器返回
index.html
文件,你没看错,我们请求css文件结果服务端返回了html文件,然后浏览器就报错了。
history路由的项目一般部署在
服务器根目录
,域名后面的路径就是前端路径
,否则需要在前端路由库
(比如VueRouter)中做额外配置,确保浏览器能从url中提取出前端路径。1
2
3
4
5
6
7const router = new VueRouter({
mode: 'history',
base: '/app/', // 设置基础路径
routes: [
// 你的路由配置
]
});例如,如果用户的 URL 是
http://example.com/app/user/profile
,那么前端路由库会将/user/profile
视为实际的路由路径
,而/app/
则被视为基础路径。
如何做

使用 HTML5
的 History API (history.pushState()
和 history.replaceState()
) 来修改
URL,而不会触发页面刷新。
要注意的是,调用这2个api都不会触发popstate
事件,只有在用户导航历史栈(通过浏览器的后退或前进按钮)时,才会触发 popstate
事件;而hashchange
事件,无论是通过js修改hash,还是点击前进后退
按钮修改hash,都会触发hashchange
事件
history.pushState(stateObj, title, url)
功能:
向浏览器的
历史栈
中添加一个新的记录,历史栈长度+1,并更新
当前 URL
,但不重新加载页面。
参数
stateObj
: 一个对象,用于存储与该状态相关联的数据,可以通过popstate
事件的事件对象event访问。1
2
3
4window.addEventListener('popstate', e => {
//console.log(e)
const path = e.state && e.state.path;
});也可以通过
history.state
属性访问。title
:通常被忽略或设为空字符串(大多数浏览器不支持)。url
:新的 URL,可以是相对路径或绝对路径,但不能改变域名,否则会报错。
history.replaceState(stateObj, title, url)
- 功能:
- 替换当前的历史记录条目,而不是添加新的条目。
- 它同样更新
当前 URL
但不刷新页面。
- 参数:与
pushState
相同。
监听 popstate
事件来响应浏览器的前进/后退按钮操作。
最终代码实现:
1 | class Router { |
vue如何做前端性能优化
前端性能优化就包括了“如何提高首屏的加载速度”。
编码优化
- 使用事件代理:使用事件委托能减少内存占用,减少不必要的重复代码。关于事件委托的介绍,可以参考前端面试—js部分 | 三叶的博客
- 使用keep-alive缓存组件:会缓存不活动的组件实例,而不是销毁它们,防止重复渲染DOM。
- 使用路由懒加载,本质是按需加载css,js文件
- 保证key值唯一,有利于diff算法复用dom,虽然key值不唯一也会提示,也不需要我们操心。
减少资源体积
这部分的内容,其实主要是模块化打包工具
帮助我们实现的,不需要我们操心。
- 压缩css,js文件:使用打包工具比如webpack,vite压缩css,js文件(删除注释,空格,合并多个文件)
- tree-shaking:使用tree-shaking移除未使用的代码,减少最终打包后的文件体积,虽然现在的打包工具都默认支持tree-shaking。
- 压缩图片体积:使用webp格式替代jpg或者png格式的图片,压缩图片体积。
加载优化
- 使用图片懒加载,我们可以手动实现图片懒加载指令
- 缓存图片,css,js文件等静态资源。在构建过程中,为静态资源文件名添加内容哈希值(例如
app.a1b2c3d4.js
),这样每次更新文件时都会生成一个新的URL,浏览器会认为这是一个全新的资源而重新下载它,而不是使用缓存,这是也是打包工具会帮忙做的事情。
总结
分析到现在,貌似需要我们控制的性能优化分案,貌似只有事件委托,使用keep-alive,使用路由懒加载 ,使用图片懒加载。
如何解决给对象添加属性视图不刷新的问题
我们从一个例子开始
定义一个p
标签,通过v-for
指令进行遍历
然后给botton
标签绑定点击事件,我们预期点击按钮时,数据新增一个属性,界面也 新增一行
1 | <p v-for="(value,key) in item" :key="key"> |
实例化一个vue
实例,定义data
属性和methods
方法
1 | const app = new Vue({ |
点击按钮,发现结果不及预期,数据虽然更新了(console
打印出了新属性),但页面并没有更新
为什么
为什么产生上面的情况呢?下面来分析一下
vue2
是用过Object.defineProperty
实现数据响应式
1 | const obj = { val: 0 } |
当我们访问val
属性或者设置foo
值的时候,都能够触发setter
与getter
但是我们为obj
添加新属性的时候,却无法触发事件属性的拦截
1 | obj.bar = '新属性' |
这是Object.defineProperty
在设计上存在的问题,无法监听到对象属性的添加,删除,只能监听已有属性的getter和setter
如何解决
可以通过**Vue.set()或者this.$set()**来给新增属性添加响应式。
Vue.set( target, key, value )
target
:可以是一个对象,也可以是一个数组key
:可以是一个字符串类型的属性,也可以是一个下标(数字)value
:值可以是任意类型
这个方法的本质就是使用Object.defineProperty()来添加一个新的响应式属性,因为直接给对象添加的属性,是不具备响应式的。
但是,我们只能监听已有属性的getter和setter,即便添加了一个响应式属性,也是无法监听到的,所以还需要通知所有依赖这个对象的Watcher(告诉它们,我新增一个属性啦),触发视图更新。
同样的,通过Vue.delete()
和this.$delete
来解决删除对象属性,视图不更新的问题。
v-if和v-for的优先级是什么
在vue2中,v-for的优先级高于v-if,也就是说会遍历所有元素,然后再通过v-if
判断是否是要渲染,即使某些项最终不满足 v-if
条件,v-for
仍会遍历这些项。
1 | <ul> |
而在vue3中,v-if
的优先级高于v-for
,所以在vue3中,上述代码会报错,会提示item未被定义;
这也意味着在vue3中,无法根据某个对象的属性,使用v-if来控制渲染。
其实最推荐的做法是只迭代并渲染需要渲染的数据,不在同一个元素上使用v-if
和v-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 | <div id="app"> {{ message }} </div> |
1 | //使用回调函数 |
如果调用nextTick的时候,没有传入回调函数,则会返回一个Promise对象,当这个Promise对象的值改变后,就能访问到最新的DOM
1 | //使用async/await |
底层实现
1 | const callbacks = [] // 存放传入nextTick的回调函数 |
callbacks
新增回调函数后,又执行了timerFunc
函数,那么这个timerFunc
函数是做什么用的呢,我们继续来看代码:
1 | export let isUsingMicroTask = false |
上述代码描述了timerFunc
的定义过程,做了四个判断,对当前环境进行不断的降级处理,尝试使用原生的Promise.then
、MutationObserver
和setImmediate
,上述三个都不支持最后使用setTimeout
。
通过四个判断可以确保,无论在何种浏览器条件下,都能定义出最合适timerFunc
。而且四种情况下定义的timerFunc
,效果都是,将flushCallbacks
放入微任务
(或者宏任务
)队列。
timerFunc
不顾一切的要把flushCallbacks
放入微任务
或者宏任务中
去执行,它究竟是何方神圣呢?让我们来一睹它的真容:
1 | function flushCallbacks () { |
来以为有多复杂的flushCallbacks
,居然不过短短的几行。它所做的事情也非常的简单,把callbacks数组复制一份,然后把callbacks置为空,最后把复制出来的数组中的每个函数依次执行一遍;所以它的作用仅仅是用来执行callbacks中的所有回调函数,也就是说,callbacks中的任务,会在微任务阶段(或者宏任务)被执行。
如何确保此时DOM是最新的?
经过上面的介绍我们知道,传入nextTick的回调函数,通常会在微任务阶段被依次执行,那又是如何确保nextTick中的回调函数访问到的DOM是最新的DOM呢?
就如同nextTick
中存在callbacks
队列一样,在vue中修改数据,会触发对应的setter,然后将对应的更新操作,push到一个异步更新队列中(不同于callbacks),然后负责清空这个异步更新队列的任务
,也会被放入微任务队列中,就如同清空callbacks
的任务:flushCallbacks
,会被timeFunc
放入微任务队列中,不过由于清空这个异步更新队列的任务,先于flushCallbacks
被执行,所以nextTick中的回调函数访问到的DOM是最新的DOM。下面用例子说明:
1 | this.msg = '我是测试文字' |
同步调用,
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 | // 同一事件循环中多次修改同一元素的样式 |
浏览器会将这三次样式修改,合并为一次渲染流程,而非逐次触发三次重排,所以不会看到样式闪烁,因为只渲染了一次。
虽然减少了渲染次数,但每次 DOM 操作仍会 立即修改内存中的 DOM 树,频繁操作可能导致主线程阻塞(如复杂布局计算),因为操作DOM是费时的(比如一个DOM对象身上有很多属性,创建一个DOM是费时间的),所以在Vue等框架中,使用虚拟DOM和diff算法,来减少操作真实DOM的次数。
虚拟DOM
虚拟DOM(虚拟DOM树)本质就是一个用来描述真实DOM(真实DOM树)的js对象,是对真实DOM(真实DOM树)的高度抽象。
1 | <div id="app"> |
将上面的HTML模版抽象成虚拟DOM树:
1 | { |
操作虚拟 DOM 的速度,比直接操作真实 DOM 快 10-100 倍。
VNode
虚拟DOM树本身是一个js对象,是对真实DOM树的高度抽象,而VNode是虚拟DOM树上的结点,是对真实DOM结点
的抽象,它描述了应该怎样去创建真实的DOM结点。
创建虚拟DOM
在Vue 通过 createElement
函数(简写为 h
,即 “hyperscript”)生成 VNode
树,每个 VNode
有 children
,children
每个元素也是一个VNode
,这样就形成了一个虚拟树结构,用于描述真实的DOM
树结构。
一个典型的 vnode
对象可能包含以下字段:
tag
: 元素类型(例如'div'
、'span'
等)data
: 包含元素的属性、样式、事件处理器等元数据children
: 子节点数组,可以是其他vnode
或文本字符串text
: 如果是文本节点,则包含文本内容el
: 引用对应的真实DOM节点(仅在某些实现中存在)
举例说明:
1 | <div class="container" style="color: red;"> |
1 | // 使用 Vue 的 render 函数和 createElement |
生成的 VNode
结构:
1 | { |
在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树的修改。
- 调用每个Watcher的
- 综上所述,在vue中对真实DOM的修改,是在微任务阶段发生的,然后就到了事件循环的末尾,因为对DOM进行了修改,所以会进行一次渲染。
说说diff算法

具体复用方式
在大多数情况下,一个 HTML 标签对应一个 DOM(Document Object Model)元素。DOM元素本身也是一个js对象,不过身上的属性要多得多。
1
<div id="example">Hello, World!</div>
这个
<div>
标签会被解析为一个HTMLElement
对象,并且可以通过 JavaScript 访问它,例如使用document.getElementById('example')
。如果父元素的虚拟 DOM 发生了变化,但其子元素的虚拟 DOM 没有变化,在大多数情况下,框架会尝试复用子元素的真实 DOM
当父元素保持不变,而其子元素发生了变化时,框架会对子树执行 diff 操作来确定哪些部分需要更新。
1
2
3<div class="parent">
<div class="child">Initial Message</div>
</div>当我们修改子元素的内部文本:
1
2
3<div class="parent">
<div class="child">Updated Message</div>
</div>父元素
<div class="parent">
及其属性没有变化,子元素<div class="child">
的文本内容从'Initial Message'
变为'Updated Message'
。由于父组件的虚拟 DOM 没有变化,父元素<div class="parent">
不会被重新创建或替换,而是继续使用现有的真实 DOM 元素。简单的来说,新旧虚拟dom的比较是结点级别的,只要某个结点的新旧虚拟dom未改变,就会复用这个dom结点
说说你对vue中key的理解
key是给每一个虚拟dom(或者说vnode)的唯一id。在diff过程中,根据key值,可以更准确, 更快的找到待比较的虚拟dom,从而优化diff算法,提高dom的复用率。
如果不设置key,那key值默认就都是undefined,将会按顺序进行新旧虚拟dom的比较。
详细可参考禹神的vue视频:030_尚硅谷Vue技术_key作用与原理_哔哩哔哩_bilibili
说说你对keep-alive的理解
keep-alive是vue中的内置组件,包裹动态组件(router-view)时,会缓存不活动的组件实例,而不是销毁它们,防止重复渲染DOM。
被缓存的组件会额外多出两个生命周期activated
和deactivated
keep-alive可以使用一些属性
,来更精细的控制组件缓存。
include
- 字符串或正则表达式或者一个数组。只有名称匹配的组件会被缓存exclude
- 字符串或正则表达式或者一个数组。任何名称匹配的组件都不会被缓存max
- 数字:最多可以缓存多少个组件实例,超出这个数字之后,则删除第一个被缓存的组件,由此可以推测存在一个缓存队列,先入先出。
1 | <keep-alive include="a,b"> |
组件名称匹配,组件名称指的到底是什么呢?
匹配首先检查组件自身的 name
选项,如果 name
选项不可用,则匹配它的局部注册名称 (父组件 components
选项的键值),匿名组件不能被匹配。
组件被缓存了,如何获取数据呢?
借助beforeRouteEnter
这个组件内的导航守卫
,或者activated
生命周期函数
1 | beforeRouteEnter(to, from, next){ |
1 | activated(){ |
面试官:说说你对keep-alive的理解是什么? | web前端面试 - 面试官系列这篇文章中还讲解了keep-alive的实现原理,看起来还是挺复杂的
vue3中的keep-alive的语法不同于vue2
基础用法,默认缓存所有页面:
1 | <router-view v-slot="{ Component }">//Component可以理解为用来替代router-view的组件,或者说当前活跃的组件 |
精确控制具体哪些组件缓存,因为再vue3中使用组件已经不再需要注册,也不需要给组件命名,所以我们控制组件(页面)缓存的依据变成了页面的路由对象,而不是组件的名称。同时,我们不再通过给keep-alive标签添加属性来控制哪些组件该被缓存,缓存多少组件,转变为借助v-if
,如果某个组件因该被缓存,那么他就会被keep-alive
标签包裹。
1 | <template> |
在路由对象中添加meta属性
1 | { |
或者
1 | <router-view v-slot="{ Component, route }"> |
但是就到此位置的话,切换页面的时候会报错:vue3 TypeError: parentComponent.ctx.deactivate is not a function 报错
网上提供的解决方案就是给每个component提供一个key。
1 | <router-view v-slot="{ Component, route }"> |
详细可参考:vue3中使用keep-alive目的:掘金
说说vue中的Mixin
mixin本质就是一个js对象,包含了vue组件任意功能选项,如data
、components
、methods
、created
、computed
等等
,被用来分发 Vue
组件中的可复用功能。
可分为全局混入和局部混入
1 | Vue.mixin({ |
1 | export default { |
如果混入组件的时候出现了功能选项冲突,一般以组件功能选项为准。
面试官:说说你对vue的mixin的理解,有什么应用场景? | web前端面试 - 面试官系列
在vue3的组合式api中,混入(mixin)显然就没有用武之地了,转而被composable
替代,下面就是一个例子,介绍了在vue3中是如何复用代码的:
1 | //useCountDown.js |
非常好理解啊,就像大多数编程语言一样,把能实现部分功能的代码封装成一个函数,需要的时候再导入这个函数,调用这个函数,和把这些代码直接写在组件中相比,区别只于私有化了变量,需要通过return导出。
跨域是什么?Vue项目中你是如何解决跨域的呢?
是什么
跨域本质是浏览器
基于同源策略的一种安全手段
,它是浏览器最核心也最基本的安全功能,服务器间通信不会有跨域的问题。
所谓同源(即指在同一个域)具有以下三个相同点
- 协议相同(protocol)
- 主机相同(host)
- 端口相同(port)
反之非同源请求,也就是协议、端口、主机其中一项不相同的时候,这时候就会产生跨域(非同源产生跨域)
举个例子,我们直接打开 HTML 文件使用的是file:///
协议加载,如果文档内部请求了其他网络资源
,因为HTTP 请求使用的是 http://
或 https://
协议,协议不同,就发生了跨域。
和跨站有什么区别呢?跨站不涉及协议和端口号,一般情况下,跨站指的就是主域名不同,比如www.bilibili.com
和game.bilibili.com
属于同站。
如何解决
- JSONP
- CORS
- Proxy
JSONP
利用了
script
标签可以跨域加载脚本动态创建一个script标签,并自定它的src属性为目标服务器的url
这个url通常包含一个查询参数,用于指定客户端上的回调函数名
服务端接收到请求后,返回包含函数调用的js代码,其中传入函数的参数,就是服务器传递的参数。
但jsonp请求有个明显的缺点:只能发送
get
请求
1 | function onClick(){ |
1 | <button onclick="onClick()">+</button> |
其实还有其他标签可以跨域加载资源,貌似大部分标签都可以跨域加载资源…
媒体资源
标签 | 作用 |
---|---|
img标签 | 可以跨域加载图像资源,但是如果给img标签加上crossorigin属性,那么就会以跨域的方式请求图片资源 |
audio和video标签 | 可以跨域加载视频,音频 |
前端基础三大文件
标签 | 作用 |
---|---|
link标签 | 可以跨域加载CSS文件 |
iframe标签 | 可以跨域加载HTML页面。 |
script标签 | 可以跨域加载脚本 |
crossorigin属性
虽然上述三大标签默认可以跨域加载资源,但是如果添加了crossorigin
属性,情况就不同了,此时加载资源同样受同源策略限制,请求这这些资源的时候,会携带Origin
头,并且要求响应头中包含Access-Control-Allow-Origin
字段。
尽管 <script>
默认允许跨域加载,但 crossorigin
属性的核心意义在于:
调试需求:前端可以获取跨域脚本的详细错误日志(开发阶段尤其关键)
安全增强:强制验证服务器是否明确允许当前来源(避免滥用第三方资源)。
特殊资源要求,例如:
字体文件:通过
<link>
加载的跨域字体必须使用crossorigin
。ES6 模块:
<script type="module">
加载的模块必须启用 CORS,所以说vue3项目打包后,引入js文件的方式如下:1
<script type="module" crossorigin src="/assets/index-RPTkaswq.js"></script>
默认添加了
crossorigin
头。
行为 | 不加 crossorigin | 加 crossorigin |
---|---|---|
是否允许跨域加载 | ✅ 允许 | ✅ 允许(需服务器支持 CORS) |
是否验证 CORS 头 | ❌ 不验证 | ✅ 必须验证 |
错误信息详情 | ❌ 仅 Script error. 跨域脚本可能包含敏感逻辑或数据,因此浏览器不会将详细的错误信息暴露给非同源页面 | ✅ 完整错误信息(需 CORS 允许) |
适用场景 | 不关心错误细节的公共库 | 需调试或加载字体/模块等特殊资源 |
Proxy
代理(Proxy)也称网络代理
,是一种特殊的网络服务
,允许一个(一般为客户端)通过代理
与另一个网络终端(一般为服务器)进行非直接的连接。一些网关、路由器等网络设备具备网络代理功能。一般认为代理服务有利于保障网络终端的隐私或安全,防止攻击。
代理的方式也可以有多种:
在脚手架中配置
在开发过程中,我们可以在
脚手架
中配置代理。我们可以通过webpack(或者vite)
为我们开起一个本地服务器
(devServer,域名一般是localhost:8080
),作为请求的代理服务器
,所以说,这个本地服务器不仅能部署
我们开发打包
的资源,还能起到代理
作用。通过该服务器
转发
请求至目标服务器,本地代理服务器得到结果再转发给前端,因为服务器之间通信不存在跨域问题,所以能解决跨域问题。打包之后的项目文件,因为脱离了代理服务器,所以说这种方式只能在
开发环境
使用。1
2
3
4
5
6
7
8
9
10
11//vue.config.js 即vue-cli脚手架(基于webpack)开发的vue项目
devServer: {
//感觉这些信息都是在告诉代理服务器该怎么做
proxy: {
'/api': {//匹配所有以/api开头的请求路径
target: 'http://localhost:3000', // 告诉代理服务器要请求的目标服务器地址
changeOrigin: true, //改变代理服务器请求目标服务器时的host,代理服务器修改host为目标服务器的域名+端口
pathRewrite: { '^/api': '' }, // 告诉代理服务器,重写路径,移除前缀
}
}
}1
2
3
4
5
6
7
8
9//vite.config.js 即vue-create脚手架(基于vite)开发的vue项目
server: {
proxy: {
'/api': {//匹配所有以/api开头的请求路径
target: 'http://localhost:3000', // 目标服务器地址
changeOrigin: true, //改变代理服务器请求目标服务器时的host,代理服务器修改host为目标服务器的域名
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,移除前缀
}
}可以看到,我们要使用代理,在编写接口时,就不能书写完整的路径,比如就不能直接把请求url写成
https://www.sanye.blog/books
,这样必然跨域,我们应该把请求写为/books
,部署到本地服务器后加载网页,发起这个请求前,会先自动与域名
拼接,实际的请求就变为http://localhost:8080/books
,这样就没跨域,不过确实,这么操作的话,就是在请求本地服务器中的books
资源,而不是目标服务器中的,如果我们本地服务器中有这个资源(vue-cli中是public目录下有books文件,无后缀),那么本地服务器就会把这个资源返回给浏览器,无论我们是否开启了代理,所以我们实际还要添加/api
类似的多余的前缀,确保我们访问的不是
本地服务器中的资源,然后本地服务器会帮我们按照配置的规则进行路径重写
,得到正确的请求URL,再向目标服务器请求资源。在服务端开启代理
其实也不是打包后就不能通过代理来解决跨域问题,如果我们把
打包后的前端资源
部署到本地的服务器
,比如使用基于node.js
的express
框架搭建的本地服务器
,我们也可以通过配置代理来解决跨域问题。1
2
3
4
5
6
7
8
9
10
11
12const 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
,
在请求头中携带Origin
,Access-Control-Request-Method
,Access-Control-Request-Headers
字段,询问服务器是否接受来自xxx源,请求方法为xxx,请求头为xxx的跨域复杂请求
,如果接受,才发送这样的复杂请求
。

服务端处理代码(以express框架为例)S
1 | app.options( '/students ',( req,res)=>{ |
这样处理起来明显比较繁琐,实际上我们借助CORS中间件
就能统一处理简单请求和复杂请求(包括预检请求)
的跨域问题。
head请求
HTTP请求方法 HEAD
,是一种用于请求资源元信息的请求方法,它与 GET
请求类似,但有一个关键的区别:服务器在响应中不会返回消息体(即实际的内容),只返回头部信息(Headers)。这意味着当你发送一个 HEAD
请求时,你只会收到关于该资源的元数据,例如内容类型、大小、最后修改时间等,而不会收到文档的实际内容。
使用场景
- 检查资源的状态:可以用来检查资源是否存在、获取资源的最新修改时间或其他头部信息,而不必下载整个资源。
- 测试链接的有效性:在不加载整个页面或资源的情况下,验证URL的有效性和可访问性。
- 性能优化:在需要了解文件大小以准备接收之前,可以通过
HEAD
请求先获取文件的大小信息。这在处理大文件下载前特别有用,因为它允许客户端决定是否继续下载。 - 缓存验证:可以用来检查本地缓存的副本是否仍然有效,通过比较缓存中的头部信息和服务器返回的头部信息。
vue项目如何部署?有遇到布署服务器后刷新404问题吗
如何部署
前后端分离开发模式下,前后端是独立布署
的,前端只需要将最后的构建物上传至目标服务器的web
容器指定的静态目录
下即可,我们知道vue
项目在构建打包后,是生成一系列的静态文件。
404问题
HTTP 404 错误意味着链接指向的资源不存在,问题在于为什么不存在?且为什么只有history
模式下会出现这个问题,而hash模式下不会有?
history模式,刷新页面,前端路由部分
会被当作请求URL
的一部分发送给服务器,然而服务器并没有相关配置
,所以响应404
。
而hash模式,前端路由在URL的#
后面,不会被当作请求URL的一部分。
要解决使用history路由的项目,刷新页面出现的404问题,必须和后端
沟通,当请求的页面不存在时,返回index.html
,把页面控制权全交给前端路由
。
但是这样有个问题,就是后端服务器不会再响应404
错误了,当找不到请求的资源总是会返回index.html,即便请求的资源在前后端中都不存在(即把页面控制权交给前端路由,也没有对应的页面),所以为了避免这种情况,应该在 Vue应用里面覆盖所有的路由情况
,最后给出一个 404 页面
(虽然说是404页面,但是响应状态码是200,因为返回了index.html
)
直接打开页面空白问题
直接打开页面,页面空白本质就是因为js文件加载失败
因为我们开发的是单页面应用程序,需要借助js操作dom来更新页面,而本身的html文件中并没有任何结构,所以如果js文件加载失败,页面就不会有任何结构,所以显示空白。
那为什么js文件会加载失败呢,原因分为两种,一种是加载js文件的路径错误,这通常出现在使用绝对资源路径的情况(使用history路由),为了得到最终的路径还会和盘符(C:或者D:)拼接,所以找不到资源。
还有一种是请求资源的时候跨域了,为什么会跨域了,我们加载的不是本地的js文件文件吗?确实,加载本地资源出现跨域,导致资源加载失败的问题,只会出现在vue3项目中,而vue2项目中不会有这个问题,为什么呢?vue3默认使用vite
构建工具,打包后会生成基于esm
的代码,浏览器在file://
协议下加载esm
时,会触更严格的跨域安全策略,导致本地的css,js文件也被视为跨域资源
,所以资源加载失败
1 | <!--可以观察到这个模块的type='module',这意味着这个js文件内使用了esm语法(比如import),这个js文件成为了esm--> |
而vue2项目通常使用webpack
打包,生成的代码通常以传统脚本
的形式加载,此时浏览器对file://
协议的跨域闲置比较宽松。
说下你的vue项目的目录结构,如果是大型项目你该怎么划分结构和划分组件呢 ?
在划分项目结构的时候,需要遵循一些基本的原则:
- 文件夹和文件夹内部文件的语义一致性
- 单一入口/出口
- 就近原则,紧耦合的文件应该放到一起,且应以相对路径引用
- 公共的文件应该以绝对路径的方式从根目录引用
/src
外的文件不应该被引入
文件夹和文件夹内部文件的语义一致性
我们的目录结构都会有一个文件夹是按照路由模块
来划分的,如pages
文件夹,这个文件夹里面应该包含我们项目所有的路由模块,并且仅
应该包含路由模块,而不应该有别的其他的非路由模块的文件夹
这样做的好处在于一眼就从 pages
文件夹看出这个项目的路由有哪些
单一入口/出口
举个例子,在pages
文件夹里面存在一个seller
文件夹,这时候seller
文件夹应该作为一个独立的模块由外部引入,并且 seller/index.js
应该作为外部引入 seller 模块的唯一入口
1 | // 错误用法 |
就近原则,紧耦合的文件应该放到一起,且应以相对路径引用
使用相对路径
可以保证模块内部的独立性
1 | // 正确用法 |
举个例子
假设我们现在的 seller 目录是在 src/pages/seller
,如果我们后续发生了路由变更,需要加一个层级,变成 src/pages/user/seller
。
如果我们采用第一种相对路径的方式,那就可以直接将整个文件夹拖过去就好,seller
文件夹内部不需要做任何变更。
但是如果我们采用第二种绝对路径的方式,移动文件夹的同时,还需要对每个 import
的路径做修改
总之就是要体会到相对路径的好处,移动文件也不需要修改路径,只要相对位置没变就好。
/src 外的文件不应该被引入
vue-cli
脚手架已经帮我们做了相关的约束了,正常我们的前端项目都会有个src
文件夹,里面放着所有的项目需要的资源,js
, css
, png
, svg
等等。src
外会放一些项目配置,依赖,环境等文件
这样的好处是方便划分项目代码文件和配置文件
Vue如何做权限管理
权限是对特定资源的访问许可,所谓权限控制,也就是确保用户只能访问到被分配的资源
而前端权限归根结底是请求的发起权
,请求的发起可能有下面两种形式触发
- 页面加载触发
- 页面上的按钮点击触发
如何做
前端权限控制可以分为四个方面:
- 接口权限
- 按钮权限
- 菜单权限
- 路由权限
接口权限
接口权限目前一般采用jwt
的形式来验证,没有通过的话一般返回401
(用户不存在),跳转到登录页面重新进行登录
登录完拿到token
,将token
存起来,通过axios
请求拦截器进行拦截,每次请求的时候头部携带token
1 | axios.interceptors.request.use(config => { |
Vue3
Vue2与Vue3有什么不同
响应式实现方法不同:vue2的响应式是基于
Object.defineProperty
实现的,而在vue3中,响应式是基于Proxy实现的api风格不同:在vue2使用的是选项式api,在vue3中既可以使用组合式api,又可以使用选项式api。
组件注册方式不同:Vue3导入组件后,也不需要在components里注册
模板语法不同:Vue2中模板里只能有一个根标签,而在Vue3里可以有多个,因为这些根标签都会被
fragment
标签包裹
Vue3做了哪些优化?
这是一个很大的话题,这里只做简要介绍
,后续对每个部分都有详细解释
。
更小
Vue2本身不支持tree-shaking,Vue 2 的核心库和其插件或附加功能(如路由管理、状态管理等)通常是作为一个整体提供的,而不是作为独立可选模块。这意味着即使你的应用只使用了 Vue 的一小部分功能,整个 Vue 库也会被打包进去。
Vue3移除了一些不常用的 API,引入
tree-shaking
,可以将无用模块“剪辑”,仅打包需要的,使打包的整体体积变小了。更快
主要体现在编译方面:diff算法优化,静态提升,事件监听缓存,SSR优化
更友好
vue3
在兼顾vue2
的options API
的同时,还推出了composition API
,大大增加了代码的逻辑组织和代码复用能力。
源码
源码可以从两个层面展开:
源码管理
vue3
整个源码是通过monorepo
的方式维护的,根据功能将不同的模块拆分到packages
目录下面不同的子目录中这样使得
模块拆分
更细化,职责划分更明确,模块之间的依赖关系也更加明确,开发人员也更容易阅读、理解和更改所有模块源码,提高代码的可维护性。TypeScript
Vue3
是基于typeScript
编写的,提供了更好的类型检查,能支持复杂的类型推断 。
性能
体积优化(支持tree-shaking),编译优化
数据劫持优化:在
vue2
中,数据劫持是通过Object.defineProperty
,这个 API 有一些缺陷,并不能检测对象属性的添加和删除1
2
3
4
5
6
7
8Object.defineProperty(data, 'a',{
get(){
// track
},
set(){
// trigger
}
})尽管
Vue
为了解决这个问题提供了set
和delete
实例方法,但是对于用户来,还是增加了一定的心智负担。相比之下,
vue3
是通过proxy
监听整个对象,而不是监听属性,那么无论是删除对象属性,还是给对象添加属性,当然也能监听到。同时Proxy
并不能监听到内部深层次的对象变化,而Vue3
的处理方式是在getter
中去递归响应式,这样的好处是真正访问到的内部对象才会变成响应式,而不是无脑递归添加响应式。
语法API
这里当然说的就是composition API
,其两大显著的优化:
- 优化逻辑组织
- 优化逻辑复用
在vue2
中,我们是通过mixin
实现功能复用,如果多个mixin
混合,会存在两个非常明显的问题:命名冲突
和数据来源不清晰
,
比如有2个提供了若干组件功能的js文件,它们在同一个组件内被混入,但是它们都提供了一个名为useMouse的方法,这个时候就产生了冲突,不知道以哪个函数为主;当在一个组件内混入了多个文件的时候,我们只能知道这个组件内混入了哪些文件,不知道具体某个功能是哪个js文件提供的。
而在组合式api中,我们可以将一些可复用的代码,抽离出来作为一个函数
并导出,在需要使用的地方导入后直接调用即可。这个种模块化的方式既解决了命名冲突
的问题,也解决了数据来源不清晰
的问题。为什么呢?因为每个函数的命名一般都是从它的功能
出发的。我们导入不同功能的函数,通常不会有命名冲突的问题;而且每个函数的功能明确,需要通过调用函数的方式拿到函数内部返回的数据,这样数据的依赖就很明确了。
1 | // mouse/index.js |
在组件中使用
1 | //导入 |
Vue3.0里为什么要用 Proxy API 替代 defineProperty API ?
defineProperty
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改对象的现有属性,并返回此对象。
1 | //传入一个对象,将这个对象转变成响应式对象 |
1 | function defineReactive(obj, key, val) { |
1 | const arrData = [1,2,3,4,5]; |
缺点小结
Object.defineProperty
无法监听到数组方法对数组元素的修改- 需要遍历对象每个属性
逐个添加监听
,而且无法监听到对象属性
的添加
与删除
,如果属性值是嵌套对象,还深层监听,造成性能问题。
Proxy
Proxy
的监听是整个对象
,那么对这个对象的所有操作会进入监听操作,这就完全可以代理所有属性了
定义一个响应式方法reactive
,这个reactive
方法就是vue3中的reactive
方法的简化版
1 | function reactive(obj) { |
测试一下简单数据的操作,发现都能劫持
1 | const state = reactive({ |
再测试嵌套对象情况,这时候发现就不那么 OK 了
1 | const state = reactive({ |
如果要解决,需要在get
之上再进行一层代理
1 | const observed = new Proxy(obj, { |
修改后输出的结果:
1 | 获取bar:[object Object] |
总结
Object.defineProperty
这个方法存在许多缺点,比如必须遍历对象
的所有属性逐个添加监听
,而且无法监听对象属性的增加与删除,如果属性的值是引用类型
还需要深度监听
,造成性能问题
。
对于数组,Object.defineProperty
方法无法监听到数组方法,对数组元素的修改,需要重写数组方法。
而Proxy能监听整个对象的变化,也能监听到数组方法对数组元素的修改。
说说Vue 3.0中Treeshaking特性?
是什么
Tree shaking
是一种通过清除多余js代码方式来优化项目打包体积
的技术。
如何做
Tree shaking
是基于ES6
模块语法(import
与exports
),主要是借助ES6
模块的静态编译
思想,在编译时
就能确定模块的依赖关系,以及输入和输出的变量。
Tree shaking
无非就是做了两件事:
- 编译阶段利用
ES6 Module
判断哪些模块已经加载 - 判断那些函数和变量未被使用或者引用,进而删除对应代码
那么为什么使用 CommonJs、AMD 等模块化方案无法支持 Tree Shaking 呢?
因为在 CommonJs、AMD、CMD 等旧版本的 js 模块化方案中,导入导出行为是高度动态,难以预测的,只能在代码运行的时候
确定模块的依赖关系,例如:
1 | if(process.env.NODE_ENV === 'development'){ |
而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句只能出现在模块顶层,可以理解为全局作用域;且导入导出的模块名必须为字符串常量,这意味着下述代码在 ESM 方案下是非法的:
1 | if(process.env.NODE_ENV === 'development'){ |
所以,ESM 下模块之间的依赖关系是高度确定
的,与运行状态无关,编译工具只需要对 ESM 模块做静态语法分析
,就可以从代码字面量中推断出哪些模块值未曾被其它模块使用,这是实现 Tree Shaking 技术的必要条件。
关于tree-shaking
更多内容参考:前端面试—webpack | 三叶的博客
关于cjs和esm
的更多内容参考:nodejs | 三叶的博客
Composition Api 与 Options Api 有什么不同?
代码组织方式:选项式api按照
代码的类型
来组织代码;而组合式api按照代码的逻辑
来组织代码,逻辑紧密关联的代码会被放到一起。代码复用方式:在选项式api这,我们使用
mixin
来实现代码复用,使用单个mixin
似乎问题不大,但是当我们一个组件混入大量不同的mixins
的时候,就存在两个非常明显的问题:命名冲突
和数据来源不清晰
而在组合式api中,我们可以将一些可复用的代码抽离出来作为
一个函数
并导出,在需要在使用的地方导入后直接调用即可。这个种模块化
的方式,既解决了命名冲突
的问题,也解决了数据来源不清晰
的问题。更多内容参考前文《Vue3做了哪些优化?》