轻松搞懂前端面试题系列(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是基于esbuildRollup,依靠浏览器自身ESM编译功能, 实现极致开发体验的新一代构建工具。Vite最明显的优势就是快,相比webpack,解决了项目启动慢和更新慢的问题,那么它是如何解决的呢?
对于开发环境和生产环境,Vite进行了不同的处理:

  1. 开发环境

    • 利用浏览器原生的ES Module编译能力,省略费时的编译环节,直给浏览器开发环境源码dev server只提供轻量服务。
    • 浏览器执行ESM的import时,会向dev server发起该模块的ajax请求,服务器对源码做简单处理后返回给浏览器。
    • Vite中HMR是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块失活,使得无论应用大小如何,HMR 始终能保持快速更新。
    • 使用esbuild处理项目依赖esbuild使用go编写,比一般node.js编写的编译器快几个数量级。
  2. 生产环境

集成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的优势与不足

  1. 优势

    • 快!快!非常快!!
    • 高度集成,开箱即用。
    • 基于ESM急速热更新,无需打包编译。
    • 基于esbuild的依赖预处理,比Webpack等node编写的编译器快几个数量级。
    • 兼容Rollup庞大的插件机制,插件开发更简洁。
    • 内置SSR支持。
    • 天然支持TS
  2. 不足

    • 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

  • 通过索引访问或设置对应元素的值时,可以触发 gettersetter 方法
  • 通过 pushunshift 会增加索引,对于新增加的属性,需要再手动初始化才能被 observe
  • 通过 popshift 删除元素,会删除并更新索引,也会触发 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之后就解决了这个问题。

解决方案

数组

  1. this.$set(array, index, data)
//这是个深度的修改,某些情况下可能导致你不希望的结果,因此最好还是慎用
this.dataArr = this.originArr
this.$set(this.dataArr, 0, {data: '修改第一个元素'})
console.log(this.dataArr)        
console.log(this.originArr)  //同样的 源数组也会被修改 在某些情况下会导致你不希望的结果 
  1. splice
// 因为splice会被监听有响应式,而splice又可以做到增删改。
  1. 利用临时变量进行中转
let tempArr = [...this.targetArr]
tempArr[0] = {data: 'test'}
this.targetArr = tempArr

对象

  1. this.$set(obj, key ,value) - 可实现增、改
  2. watch时添加deep:true深度监听,只能监听到属性值的变化,新增、删除属性无法监听
this.$watch('blog', this.getCatalog, {
  deep: true
  // immediate: true // 是否第一次触发
});
  1. watch时直接监听某个key
watch: {
  'obj.name'(curVal, oldVal) {
    // TODO
  }
}
posted @ 2022-08-03 16:04  来亦何哀  阅读(162)  评论(0编辑  收藏  举报