前端开发的十万个为什么(一)


好久不见了,在研一结束一周后就匆匆忙忙开启了漫长的日常实习生活。无数次面试被挂的经验告诉我,只是简单的明白怎么用 API 是不够进入大厂的。因此,还是老方法,「用输出倒逼输入」,为了解决惰性带来的浅入浅出的问题,我依然决定拿时间换能力。这里会总结并分享一些我在实际开发过程中遇到的一些困惑和相关的探索。 感谢 ChatGPT 对本文的大力支持!🤖️

面向实习中遇到的知识点,偏 Vue

微前端到底是好还是坏?

一句话总结:让项目容易上手,适合 ToB 场景下多项目的敏捷开发,但全局的项目管理非常困难。

所有的微前端项目独立于各自的仓库中,每个项目可以独立的加载自己的 JS,CSS 文件。 这带来的好处显而易见,如果我只需要开发其中一个项目,那么我可以单纯 clone 这个仓库,然后提对应的 PR,合进 master 分支,并单独部署。针对单个项目的开发而言,可以说是非常舒服。 另一方面,这种模式也允许我们的子项目可以做到技术栈无关,针对有些 vue2 的老项目,可以不用重构,直接以子应用的方式独立的进行开发和部署。不同的子应用之间也可能应用不同的组件库,这也给我们的设计与开发带来了更加自由的选择。

这种架构模式之下,也会带来同样多的麻烦。

  1. 微前端的状态管理是相对繁琐的,需要额外选择微前端框架(例如 Wujie、Qiankun),针对每个子应用,都需要单独且重复地配置一遍权限认证等,即使已经封装了对应hooks和packages。并且,可能还需要额外处理打包工具和这些前端框架的兼容性问题(例如 Qiankun 和 Vite)。
  2. 如果想要实现跨子应用的组件复用,需要单独维护一个 NPM 组件库,在库中单独管理和发布公用组件,这无疑会增加组件复用时的时间耗费。
  3. 如果想在本地预览子应用,可能会需要在本地启动多个项目(外层>子应用>子应用>…)。

Vue,React 和 Svelte 这些在 Reactivity 上有什么区别?

ES6 中的 Proxy 无法为原始值提供代理,包括 Boolean, Number, BigInt, String, undefined 等。 而其他的非原始类型均可使用 Proxy 来实现对象代理,包括 Object, Array, Map, Set, WeakMap 等。

Vue3

针对 Vue3 中的 ref 实现

原始值的响应式更新:通过创建 RefImpl 类来实现创建 ref 的实例,拦截数据的 读取Get设置Set 操作,在 get 的时候通过 trackerEffects 来收集 value 的依赖,在 set 的时候通过 triggerEffects 派发 value 的更新。

非原始值的响应式更新:根据设定的深浅响应,除了需要创建单独的 GetterSetter 来捕获数据的读取和更新,还需要通过 has 拦截函数拦截 in 操作符,使用 ownKeys 来拦截 for...in 循环,以及对 delete 的拦截等等。

React

针对 React 16.8 提出的 useState

在组件内部,通过闭包的方式创建和维护状态的数组和更新对应状态的方法,包括 setter 数组和 state 数组。使用 cursor 来维护当前的状态指针,每一次重渲染都会将 cursor 置为 0。

这里可以引申到「为什么hooks不能放在判断、循环中」,因为 hooks 的都只能根据调用顺序来。因为组件实例确定后,hooks 只能拿到初始值和调用次数,例如 useState 是拿不到状态变量的名称的。

Svelte

独立的编译过程,不去手动维护虚拟 DOM。每个组件都是一个独立的 JS类(Class),继承 SvelteComponent,Svelte 负责替换原生 DOM 算法来优化 DOM 更新操作。

对应的组件实例通过 instance 实例方法去创建状态和更新状态的相关方法:

function instance($$self, $$props, $$invalipubDate) {
  let count = 0;
  const click_handler = () => $$invalipubDate(0, count++, count);
  return [count, click_handler];
}

实例化组件的时候执行 init 方法标注脏组件(有需要重新 render 的状态和更新方法),然后在状态变化的时候通过 flush() 方法去遍历更新组件。

v-ifv-show 有什么区别?

Conditional Rendering | Vue.js (vuejs.org)

  • 两者本身都是控制动态渲染组件
  • v-if 是真实的条件渲染,会在切换过程中销毁和重建块内的事件监听器和子组件。
  • v-if 也是惰性的,如果初次渲染时为 false,就什么都不会做,直到为 true 时才会局部编译。
  • v-show 只是单纯的 CSS 样式切换,具体来说就是 display: none 的开关。
  • v-if 切换开销更高,v-show 的初始渲染开销更高。如果需要频繁切换,就用 v-show;如果运行时绑定条件很少改变,就用 v-if

为什么 vant 组件库的 Popup onMounted 里无法捕获到 ref?

问题分析:通过查看 vant 库的文档和源码,发现这个组件默认设置了 lazyRender,通过监听 props.show 决定是否要 render 对应的 DOM 结构。我们主组件 mount 的时候,虽然也会 mount Popup 组件,但是这个时候 Popup 组件的 DOM 树是空的,在 onMounted 钩子里拿到对应的结点引用为 undefined

解决方案:

  1. lazyRender 属性设置为 false,这个时候 Popup 组件在 Mount 的时候就会全部加载。理论上会对性能有轻微的影响。
  2. Popup 组件内手动 Watch 一下 show 属性,在 show 属性发生变化的时候,对应的 ref 就是能正常引用的。

高度不定的元素该怎么设计 transition 动画?

问题分析:height: auto/100% 不会触发过渡动画,因为 transition 的过渡动画需要元素拥有确定的高度(即通过已知的起点和终点来计算逐帧的变化),例如 height: 300px => height: 100px

解决方案:

  1. max-height:可以为高度不定的元素设定 max-height 值,transition 动画会根据这个值的变化来进行渲染过渡的动画。局限:当目标高度比 max-height 小的时候,实际的过渡动画持续时间会小于手动设定的 transition 持续时间。
  2. grid:grid 布局的尺寸计算是根据最小高度来计算的,支持在 0fr => 1fr 的时候应用过渡动画。通过设定 grid 内容器样式的 min-height 设定展开前动画。

Vue 里的 nextTick 的触发时机?

nextTick 函数用于实现在下次 DOM 更新循环结束之后延迟回调,回调时机是当前 JS 执行结束之后,在下一个 DOM 更新循环之前。在 nextTick 之后,可以访问更新后的 DOM,比如元素的新尺寸或者位置等。在 JS 的事件循环中,nextTick 属于是微任务。

我们在实际开发过程中,常用的几个异步函数,分别是 promise(微), nextTick(宏/微), requestAnimationFrame(宏), setTimeout(宏),执行顺序是怎样的?

// promise => nextTick => requestAnimationFrame => timeout
nextTick(() => {
  console.log('nextTick')
})

setTimeout(() => {
  console.log('timeout')
}, 0)

requestAnimationFrame(() => {
  console.log('requestAnimationFrame')
})

Promise.resolve().then(() => {
  console.log('promise')
})

Vue 中的 computed 和 watch 是怎么实现的,分别适合怎样的场景?

core/packages/reactivity/src/computed.ts at main · vuejs/core (github.com)

computed 方法依赖于 ComputedRefImpl 类来创建了一个可以自定义 gettersetter 方法的响应式 ref。为了实现响应式的更新,ComputedRefImpl 类中又通过 ReactiveEffect 类新建了 effect 实例来管理 value 的更新。

core/packages/runtime-core/src/apiWatch.ts at main · vuejs/core (github.com)

watch 方法通过指定 sourcecallback 来实现更加直观的响应式更新,对于 source 而言,watch 方法会根据是 ref/reactive/array/function 来设置不同的 getter。 方法内部通过 EffectScheduler 类的实例来管理 callback 的调用时机,默认是 render 前,但是还可以设定为同步或者 render 后。 watch 方法和 computed 方法一样,使用 ReactiveEffect 类,来实现响应式更新。

这两个方法原理类似,使用场景稍微有些不同,computed 适用于比较直接的根据其他响应式数据计算结果,自带缓存。watch 的控制更加细粒度,通过监听数据的变化,执行特定的逻辑。

Event 的触发顺序是怎么样的?event.prevent / event.stop 是怎么实现的,分别有什么作用?

DOM 的标准事件模型中规定的事件触发顺序:捕获阶段、目标阶段和冒泡阶段。 即从外到里,目标本身事件触发,从里到外。

在 Vue 中,事件处理函数默认是在冒泡阶段执行的。 preventstop 分别是对事件进行额外的限制,分别表示 阻止事件的默认行为停止事件的冒泡。 实现方式就是在修饰符之后,额外调用 e.preventDefault 以及 e.preventPropagation

Vue 中有哪些常用的性能优化手段?

性能优化 | Vue.js (vuejs.org)

  1. 架构选择:根据产品的特性选择合适的架构,利用 SSR (服务端渲染)或者 SSG(静态站点生成)来渲染可能的页面,以缩短首屏加载时间。
  2. Tree-shake:减小构建的包的大小,一般这种由打包工具实现(webpack、Vite)。并且,在引入依赖的时候,需要注意依赖包是否过重,尽量选择 ES 模块格式的依赖。
  3. 代码分割:将部分组件利用 defineAsyncComponent 设置为异步组件,只在必要的时刻按需加载。特别是基于 Vue-Router,将页面级别的组件以异步方式引入。
  4. 减少不必要的更新:
  • 传给子组件的 props 应该尽量保持稳定
  • 使用 v-once 实现只初始化一次组件
  • 使用 v-memo="[vA, vB]" 让组件只在 vAvB 改变的时候才会更新
  1. 对大型列表实现列表虚拟化
  2. 使用 shallowRefshallowReactive 来避免不必要的深度响应
  3. 去掉不必要的组件抽象(维护虚拟 DOM 的 cost 太高)

Vue 框架自身利用编译和运行时的强耦合来对 Virtual DOM 进行的特殊优化:[[面向 Vue 中 VNode 的一次 Deep Dive]]

Vue 3 的生命周期对外暴露了哪些 hooks?

Vue3 Lifecycle:

img

Vue3 中使用 reactive 或 ref 在什么时候会失去响应性?

reactive

  • 在遇到解构赋值的时候,一层的对象包裹的属性会丢失响应性。
  • 在重新整个赋值对象的时候,整个对象都会失去响应性。(这种情况编辑器会报警)
<template>
  <div class="flex flex-col gap-2">
    <span> foo: {{ foo }} </span>
    <el-button @click="foo++">foo++</el-button>
  </div>

  <div class="flex flex-col gap-2">
    <span> bar: {{ bar }} </span>
    <el-button @click="bar.val++">bar.val++</el-button>
  </div>

  <div class="flex flex-col gap-2">
    <span> p1.age: {{ p1.age }} </span>
    <el-button @click="() => (p1 = { age: 10 })">p1.age</el-button>
  </div>
</template>

<script lang="ts" setup>
import { reactive } from 'vue'

// 解构赋值
const { foo, bar } = reactive({
  foo: 1,
  bar: {
    val: 2,
  },
})

const p1 = reactive({ name: 'ethan' })
</script>

ref:同理,将对应的 .value 赋给一个非响应式对象之后就不会触发更新了。

<template>
  <span> x: {{ x }} </span>
  <el-button @click="x++"> x++ </el-button>

  <span> y: {{ y }} </span>
  <el-button @click="y++"> y++ </el-button>
</template>

<script lang="ts" setup>
import { ref } from 'vue'

const x = ref(1)
const y = x.value
</script>

Vue3 中提示的 Suspense 特性是啥?

Vue3 的项目经常会有一个 warning:<Suspense> is an experimental feature.

<Suspense> 用于包裹一些异步组件,使用 <template #fallback> 来定义加载时显示的内容。 异步组件特指那些 async setup() 函数定义的组件,或者在 <script setup> 标签包裹的顶层中有 await 表达式的组件。

在异步组件的加载过程中,即 await setup() 执行完成之前,Suspense 组件会默认渲染 #fallback 中的内容,例如骨架屏,或者是加载动画等。

和之前相比,Suspense 的优势在于:

  • 之前我们需要手动在组件内部,定义 isLoading 的状态去维护当前组件的加载状态。
  • 之前在加载异步组件的过程中,如果不用本地的状态去管理异步的情况,用户可能看到不完整的界面。
  • 以前异步的处理逻辑分布在各个组件内部,现在更加集中。

Vue3 什么场景下会用到 shallowRef/shallowReactive

shallowRef 最大的特性在于只在整个引用发生变化的时候才会触发重渲染,例如:

const v = shallowRef({ name: 'ethan', age: 24 })
v.value.name = 'ethanloo' // 不触发 rerender
v.value = { name: 'ethanloo', age: 25 } // 触发 rerender

shallowReactive 也是类似,只在浅层的属性变更时触发重渲染:

const v = shallowReactive({ a: 1, b: { c: 1 } })
v.a = 2 // 触发 rerender
v.b.c = 3 // 不触发 rerender

在特定的场景下,shallowRef/shallowReactive 可以起到性能优化的作用:

  1. 当维护一个大型数组/对象的时候,避免深层监听的多重递归导致的性能下降。
  2. 一些简单的状态管理场景,只关心对象什么时候被替换,不关心对象内部某个属性的变化。