轻松搞懂前端面试题系列(vue篇二)
一、说下Vite的原理
将Vite
之前,需要先从与Vite
紧密相关的两个概念的发展史说起,一个是JavaScript的模块化标准,另一个是前端构建工具。
共存的模块化标准
为什么JavaScript会有多种共存的模块化标准?因为js在设计之初并没有模块化的概念,随着前端业务复杂度不断提高,模块化越来越受到开发者的重视,社区开始涌现多种模块化解决方案,它们相互借鉴,也争议不断,形成多个派系,从CommonJS
开始,到ES6正式推出ES Modules
规范结束,所有争论,终成历史,ES Modules
也成为前端重要的基础设施。
- CommonJS:现主要用于Node.js(Node@13.2.0开始支持直接使用ES Module)
- AMD:require.js 依赖前置,市场存量不建议使用
- CMD:sea.js 就近执行,市场存量不建议使用
- ES Module:ES语言规范,标准,趋势,未来
发展中的构建工具
在ES Module
规范出现之前,以前的浏览器是不支持ES module
的,比如:
// index.js
import { add } from './add.js'
import { sub } from './sub.js'
console.log(add(1, 2))
console.log(sub(1, 2))
// add.js
export const add = (a, b) => a + b
// sub.js
export const sub = (a, b) => a - b
这样的一段代码,放到浏览器无法直接运行。那怎么解决呢?这时候打包工具出场了,他将index.js、add.js、sub.js
这三个文件打包在一个bundle.js
文件里,然后在项目index.html
中直接引入bundle.js
,从而达到代码效果。
近些年前端工程化发展迅速,各种构建工具层出不穷,目前Webpack
仍然占据统治地位,npm
每周下载量达到两千多万次,下面是按 npm
发版时间线列出的开发者比较熟知的一些构建工具。
当前工程化痛点
Webpack
等构建工具的诞生给前端开发带来了极大的便利,但随着前端业务的复杂化,js代码量呈指数增长,打包构建时间越来越久,dev server
(开发服务器)性能遇到瓶颈:
- 缓慢的服务启动: 大型项目中
dev server
启动时间达到几十秒甚至几分钟。 - 缓慢的HMR热更新: 即使采用了 HMR 模式,其热更新速度也会随着应用规模的增长而显著下降,已达到性能瓶颈,无多少优化空间。
缓慢的开发环境,大大降低了开发者的幸福感,在以上背景下Vite
应运而生。
什么是Vite?
Vite
是基于esbuild
与Rollup
,依靠浏览器自身ESM编译功能, 实现极致开发体验的新一代构建工具。Vite
最明显的优势就是快,相比webpack
,解决了项目启动慢和更新慢的问题,那么它是如何解决的呢?
对于开发环境和生产环境,Vite
进行了不同的处理:
-
开发环境
- 利用浏览器原生的
ES Module
编译能力,省略费时的编译环节,直给浏览器开发环境源码,dev server
只提供轻量服务。 - 浏览器执行ESM的
import
时,会向dev server
发起该模块的ajax
请求,服务器对源码做简单处理后返回给浏览器。 Vite
中HMR是在原生 ESM 上执行的。当编辑一个文件时,Vite
只需要精确地使已编辑的模块失活,使得无论应用大小如何,HMR 始终能保持快速更新。- 使用
esbuild
处理项目依赖,esbuild
使用go编写,比一般node.js编写的编译器快几个数量级。
- 利用浏览器原生的
-
生产环境
集成Rollup
打包生产环境代码,依赖其成熟稳定的生态与更简洁的插件机制。
解决缓慢的服务启动
第一张图,是以前的打包模式,就像之前举的index.js、add.js、sub.js的例子,项目启动时,需要先将所有文件打包成一个文件bundle.js,然后在html引入,这个多文件 -> bundle.js的过程是非常耗时间的。
第二张图,是Vite
的打包方式,刚刚说了,Vite
是直接把转换后的es module
的JavaScript代码,扔给支持es module
的浏览器,让浏览器自己去加载依赖,也就是把压力丢给了浏览器,从而达到了项目启动速度快的效果。
解决缓慢的HMR热更新
项目启动时,将模块分成依赖和源码,当你更新代码时,依赖就不需要重新加载,只需要精准地找到是哪个源码的文件更新了,更新相对应的文件就行了。这样做使得更新速度非常快。
依赖: 指开发不会变动的部分(npm包、UI组件库),esbuild进行预构建
源码: 浏览器不能直接执行的非js代码(.jsx、.css、.vue等),vite只在浏览器请求相关源码的时候进行转换,以提供ESM源码
Vite
同时利用 HTTP
头来加速整个页面的重新加载(再次让浏览器为我们做更多事情):源码模块的请求会根据 304 Not Modified
进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable
进行强缓存,因此一旦被缓存它们将不需要再次请求。
Vite的优势与不足
-
优势
- 快!快!非常快!!
- 高度集成,开箱即用。
- 基于ESM急速热更新,无需打包编译。
- 基于
esbuild
的依赖预处理,比Webpack
等node编写的编译器快几个数量级。 - 兼容
Rollup
庞大的插件机制,插件开发更简洁。 - 内置
SSR
支持。 - 天然支持
TS
。
-
不足
Vue
仍为第一优先支持,量身定做的编译插件,对React
的支持不如Vue
强大。- 虽然已经推出2.0正式版,已经可以用于正式线上生产,但目前市场上实践少。
- 生产环境集成
Rollup
打包,与开发环境最终执行的代码不一致。
二、vue路由中,history和hash两种模式有什么区别?
vue的单页面应用是基于路由和组件的,路由用于设定访问路径,并将路径和组件映射起来,路由模块的本质就是建立起url和页面之间的映射关系。前端路由有两种模式:hash
模式和 history
模式,接下来分析这两种模式的实现方式和优缺点。
hash 模式
使用 URL 的 hash 来模拟一个完整的 URL,于是当 URL 改变时,页面不会重新加载。hash(#)是 URL 的锚点,代表的是网页中的一个位置,单单改变 # 后的部分,浏览器只会滚动到相应位置,不会重新加载网页,也就是说 hash 出现在 URL 中,但不会被包含在 http 请求中,对后端完全没有影响,因此改变 hash 不会重新加载页面,同时每一次改变 # 后的部分,都会在浏览器的访问历史中增加一个记录,使用后退按钮,就可以回到上一个位置,所以说 hash 模式是一种把前端路由的路径用井号 # 拼接在真实 URL 后面的模式。
如何简单实现:
// hash.html
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>hash router</title>
</head>
<body>
<ul>
<li><a href="#/">turn yellow</a></li>
<li><a href="#/blue">turn blue</a></li>
<li><a href="#/green">turn green</a></li>
</ul>
<button>back</button>
<script src="./hash.js" charset="utf-8"></script>
</body>
</html>
// hash.js
class Routers {
constructor() {
// 储存hash与callback键值对
this.routes = {};
// 当前hash
this.currentUrl = '';
// 记录出现过的hash
this.history = [];
// 作为指针,默认指向this.history的末尾,根据后退前进指向history中不同的haash
this.currentIndex = this.history.length - 1;
this.refresh = this.refresh.bind(this);
this.backOff = this.backOff.bind(this);
// 默认不是后退操作
this.isBack = false;
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}
route(path, callback) {
this.routes[path] = callback || function() {};
}
refresh() {
this.currentUrl = location.hash.slice(1) || '/';
if (!this.isBack) {
// 如果不是后退操作,且当前指针小于数组总长度,直接截取指针之前的部分储存下来
// 此操作来避免当点击后退按钮之后,再进行正常跳转,指针会停留在原地,而数组添加新hash路由
// 避免再次造成指针的不匹配,我们直接截取指针之前的数组
// 此操作同时与浏览器自带后退功能的行为保持一致
if (this.currentIndex < this.history.length - 1)
this.history = this.history.slice(0, this.currentIndex + 1);
this.history.push(this.currentUrl);
this.currentIndex++;
}
this.routes[this.currentUrl]();
console.log('指针:', this.currentIndex, 'history:', this.history);
this.isBack = false;
}
// 后退功能
backOff() {
// 后退操作设置为true
this.isBack = true;
this.currentIndex <= 0
? (this.currentIndex = 0)
: (this.currentIndex = this.currentIndex - 1);
location.hash = `#${this.history[this.currentIndex]}`;
this.routes[this.history[this.currentIndex]]();
}
}
window.Router = new Routers();
const content = document.querySelector('body');
const button = document.querySelector('button');
function changeBgColor(color) {
content.style.backgroundColor = color;
}
Router.route('/', function() {
changeBgColor('yellow');
});
Router.route('/blue', function() {
changeBgColor('blue');
});
Router.route('/green', function() {
changeBgColor('green');
});
button.addEventListener('click', Router.backOff, false);
总结一下 hash 模式的优缺点:
- 优点:浏览器兼容性较好,连 IE8 都支持
- 缺点:路径在井号 # 的后面,比较丑
history 模式
history API
是 H5 提供的新特性,允许开发者直接更改前端路由,即更新浏览器 URL 地址而不重新发起请求。
在history
对象有很多方法:
其中常用的只有几种:
history.replaceState({}, null, '/b') // 替换路由
history.pushState({}, null, '/a') // 路由压栈
history.back() // 返回
history.forward() // 前进
history.go(-2) // 后退2次
history.pushState
用于在浏览历史中添加历史记录,但是并不触发跳转,此方法接受三个参数,依次为:
state:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null。
title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null。
url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。
history.replaceState
方法的参数与pushState
一样,区别是它修改浏览历史中当前纪录,而非添加记录,同样不触发跳转。
popstate
事件,每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate
事件。
注意:popstate事件监听不到 history 的 pushState 和 replaceState方法
浏览器在刷新的时候,会按照路径发送真实的资源请求,如果这个路径是前端通过 history API
设置的 URL
,那么在服务端往往不存在这个资源,于是就返回 404 了,因此在线上部署基于 history API
的单页面应用的时候,一定要后端配合支持才行,否则会出现大量的 404。以最常用的 Nginx
为例,只需要在配置的 location /
中增加下面一行即可:
try_files $uri /index.html;
如何简单实现:
class Routers {
constructor() {
this.routes = {};
this._bindPopState();
}
init(path) {
history.replaceState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
route(path, callback) {
this.routes[path] = callback || function() {};
}
go(path) {
history.pushState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
_bindPopState() {
window.addEventListener('popstate', e => {
const path = e.state && e.state.path;
this.routes[path] && this.routes[path]();
});
}
}
window.Router = new Routers();
Router.init(location.pathname);
const content = document.querySelector('body');
const ul = document.querySelector('ul');
function changeBgColor(color) {
content.style.backgroundColor = color;
}
Router.route('/', function() {
changeBgColor('yellow');
});
Router.route('/blue', function() {
changeBgColor('blue');
});
Router.route('/green', function() {
changeBgColor('green');
});
ul.addEventListener('click', e => {
if (e.target.tagName === 'A') {
e.preventDefault();
Router.go(e.target.getAttribute('href'));
}
});
总结一下 history
模式的优缺点:
- 优点:路径比较正规,没有井号 #
- 缺点:兼容性不如 hash,且需要服务端支持,否则一刷新页面就404了
三、Vue2.0为什么不能检查数组的变化,该怎么解决?
我们都知道,Vue2.0
对于响应式数据的实现有一些不足:
- 无法检测数组/对象的新增
- 无法检测通过索引改变数组的操作。
为什么?
官方文档中对于这两点都是简要的概括为由于JavaScript的限制无法实现,而Object.defineProperty
是实现检测数据改变的方案,那这个限制是指Object.defineProperty
吗?
其实不是,Object.defineProperty
在数组中的表现和在对象中的表现是一致的,数组的索引就可以看做是对象中的 key
。
- 通过索引访问或设置对应元素的值时,可以触发
getter
和setter
方法 - 通过
push
或unshift
会增加索引,对于新增加的属性,需要再手动初始化才能被observe
。 - 通过
pop
或shift
删除元素,会删除并更新索引,也会触发 setter 和 getter 方法。
测试:
function defineReactive (data, key, value) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function defineGet () {
console.log(`get key: ${key} value: ${value}`)
return value
},
set: function defineSet (newVal) {
console.log(`set key: ${key} value: ${newVal}`)
value = newVal
}
})
}
function observe (data) {
Object.keys(data).forEach(function (key) {
defineReactive(data, key, data[key])
})
}
let arr = [1, 2, 3]
observe(arr)
通过索引改变arr[1]
,我们发现触发了set
,所以, Object.defineProperty
是有监控数组下标变化的能力的。
那Vue2.0为什么没有实现呢? 一位开发小哥在github对尤大大提了issue,回答如下:
小结:是出于对性能原因的考虑,没有去实现它。而不是不能实现。
对于对象而言,每一次的数据变更都会对对象的属性进行一次枚举,一般对象本身的属性数量有限,所以对于遍历枚举等方式产生的性能损耗可以忽略不计,但是对于数组而言呢?数组包含的元素量是可能达到成千上万,假设对于每一次数组元素的更新都触发了枚举/遍历,其带来的性能损耗将与获得的用户体验不成正比,故vue无法检测数组的变动。
不过Vue3.0用proxy
代替了defineProperty
之后就解决了这个问题。
解决方案
数组
this.$set(array, index, data)
//这是个深度的修改,某些情况下可能导致你不希望的结果,因此最好还是慎用
this.dataArr = this.originArr
this.$set(this.dataArr, 0, {data: '修改第一个元素'})
console.log(this.dataArr)
console.log(this.originArr) //同样的 源数组也会被修改 在某些情况下会导致你不希望的结果
splice
// 因为splice会被监听有响应式,而splice又可以做到增删改。
- 利用临时变量进行中转
let tempArr = [...this.targetArr]
tempArr[0] = {data: 'test'}
this.targetArr = tempArr
对象
this.$set(obj, key ,value)
- 可实现增、改watch
时添加deep:true
深度监听,只能监听到属性值的变化,新增、删除属性无法监听
this.$watch('blog', this.getCatalog, {
deep: true
// immediate: true // 是否第一次触发
});
watch
时直接监听某个key
watch: {
'obj.name'(curVal, oldVal) {
// TODO
}
}