2023.04.09 更新前端面试问题总结(10道题)

2023.04.05 - 2023.04.09 更新前端面试问题总结(10道题)
获取更多面试问题可以访问
github 地址: https://github.com/pro-collection/interview-question/issues
gitee 地址: https://gitee.com/yanleweb/interview-question/issues

目录:

中级开发者相关问题【共计 6 道题】

260.介绍下 BFC、IFC、GFC 和 FFC?【CSS】【出题公司: 百度】

BFC(Block Formatting Contexts)块级格式化上下文

什么是BFC?

BFC 全称:Block Formatting Context, 名为 块级格式化上下文

W3C官方解释为:BFC它决定了元素如何对其内容进行定位,以及与其它元素的关系和相互作用,当涉及到可视化布局时,Block Formatting Context提供了一个环境,HTML在这个环境中按照一定的规则进行布局。

如何触发BFC?

BFC布局规则

BFC应用场景

解决块级元素垂直方向margin重叠

我们来看下面这种情况:



    box1
    box2

按我们习惯性思维,上面这个box的margin-bottom是60px,下面这个box的margin-top也是60px,那他们垂直的间距按道理来说应该是120px才对。(可事实并非如此,我们可以来具体看一下)

这种情况下的margin边距为两者的最大值,而不是两者相加,那么我们可以使用BFC来解决这种margin塌陷的问题。



    
        nanjiu
    
    南玖

解决高度塌陷问题

我们再来看这种情况,内部box使用float脱离了普通文档流,导致外层容器没办法撑起高度,使得背景颜色没有显示出来。



    
        nanjiu
        南玖
    

我们可以看到此时的外层容器的高度为0,导致背景颜色没有渲染出来,这种情况我们同样可以使用BFC来解决,可以直接为外层容器触发BFC,我们来看看效果:



    
        nanjiu
        南玖
    

清除浮动

在早期前端页面大多喜欢用浮动来布局,但浮动元素脱离普通文档流,会覆盖旁边内容:



    
        nanjiu
        南玖
    

我们可以通过触发后面这个元素形成BFC,从而来清楚浮动元素对其布局造成的影响



    
        nanjiu
        南玖
    

什么是IFC?

IFC全称:Inline Formatting Context,名为行级格式化上下文

如何触发IFC?

形成条件非常简单,需要注意的是当IFC中有块级元素插入时,会产生两个匿名块将父元素分割开来,产生两个IFC。

IFC布局规则

IFC应用场景

元素水平居中

当一个块要在环境中水平居中时,设置其为inline-block则会在外层产生IFC,通过text-align则可以使其水平居中。



    
        string 1
        string 2
    

多行文本水平垂直居中

创建一个IFC,然后设置其vertical-align:middle,其他行内元素则可以在此父元素下垂直居中。



  
    

string 1 string 2

GFC(Grid Formatting Contexts)栅格格式化上下文

什么是GFC?

GFC全称:Grids Formatting Contexts,名为网格格式上下文

简介: CSS3引入的一种新的布局模型——Grids网格布局,目前暂未推广使用,使用频率较低,简单了解即可。 Grid 布局与 Flex 布局有一定的相似性,都可以指定容器内部多个项目的位置。但是,它们也存在重大区别。 Flex 布局是轴线布局,只能指定"项目"针对轴线的位置,可以看作是一维布局。Grid 布局则是将容器划分成"行"和"列",产生单元格,然后指定"项目所在"的单元格,可以看作是二维布局。Grid 布局远比 Flex 布局强大。

如何触发GFC?

当为一个元素设置display值为grid或者inline-grid的时候,此元素将会获得一个独立的渲染区域。

GFC布局规则

通过在网格容器(grid container)上定义网格定义行(grid definition rows)和网格定义列(grid definition columns)属性各在网格项目(grid item)上定义网格行(grid row)和网格列(grid columns)为每一个网格项目(grid item)定义位置和空间(具体可以在MDN上查看)

GFC应用场景

任意魔方布局

这个布局使用用GFC可以轻松实现自由拼接效果,换成其他方法,一般会使用相对/绝对定位,或者flex来实现自由拼接效果,复杂程度将会提升好几个等级。



  
    1
    2
    3
    4
    5
    6
    7
  

FFC(Flex Formatting Contexts)弹性格式化上下文

什么是FFC?

FFC全称:Flex Formatting Contexts,名为弹性格式上下文

简介: CSS3引入了一种新的布局模型——flex布局。 flex是flexible box的缩写,一般称之为弹性盒模型。和CSS3其他属性不一样,flexbox并不是一个属性,而是一个模块,包括多个CSS3属性。flex布局提供一种更加有效的方式来进行容器内的项目布局,以适应各种类型的显示设备和各种尺寸的屏幕,使用Flex box布局实际上就是声明创建了FFC(自适应格式上下文)

如何触发FFC?

当 display 的值为 flex 或 inline-flex 时,将生成弹性容器(Flex Containers), 一个弹性容器为其内容建立了一个新的弹性格式化上下文环境(FFC)

FFC布局规则

**⚠️注意:**FFC布局中,float、clear、vertical-align属性不会生效。

Flex 布局是轴线布局,只能指定"项目"针对轴线的位置,可以看作是一维布局。Grid 布局则是将容器划分成"行"和"列",产生单元格,然后指定"项目所在"的单元格,可以看作是二维布局。Grid 布局远比 Flex 布局强大。

FFC应用场景

这里只介绍它对于其它布局所相对来说更方便的特点,其实flex布局现在是非常普遍的,很多前端人员都喜欢用flex来写页面布局,操作方便且灵活,兼容性好。

自动撑开剩余高度/宽度

看一个经典两栏布局:左边为侧边导航栏,右边为内容区域,用我们之前的常规布局,可能就需要使用到css的calc方法来动态计算剩余填充宽度了,但如果使用flex布局的话,只需要一个属性就能解决这个问题:

calc动态计算方法:



		
        box1
        box2
    

使用FFC:



		
        box1
        box2
    

参考文档

262.数组里面有10万个数据,取第一个元素和第10万个元素的时间相差多少?【JavaScript】

数组可以直接根据索引取的对应的元素,所以不管取哪个位置的元素的时间复杂度都是 O(1)

得出结论:消耗时间几乎一致,差异可以忽略不计

原因:

JavaScript 没有真正意义上的数组,所有的数组其实是对象,其“索引”看起来是数字,其实会被转换成字符串,作为属性名(对象的 key)来使用。所以无论是取第 1 个还是取第 10 万个元素,都是用 key 精确查找哈希表的过程,其消耗时间大致相同。

Chrome 浏览器JS引擎 V8中,数组有两种存储模式,一种是类似C语言中的线性结构存储(索引值连续,且都是正整数的情况下),一种是采用Hash结构存储(索引值为负数,数组稀疏,间隔比较大)

263.算法题之「移动零」【JavaScript】

题目如下

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

示例:
输入: [0,1,0,3,12]
输出: [1,3,12,0,0]

说明:
必须在原数组上操作,不能拷贝额外的数组。
尽量减少操作次数。

解法

解法1:

function zeroMove(array) {
  let len = array.length;
  let j = 0;
  for (let i = 0; i < len - j; i++) {
    if (array[i] === 0) {
      array.push(0);
      array.splice(i, 1);
      i--;
      j++;
    }
  }
  return array;
}

解法2:算法思路

function moveZeroToLast(arr) {
  let index = 0;
  for (let i = 0, length = arr.length; i < length; i++) {
    if (arr[i] === 0) {
      index++;
    } else if (index !== 0) {
      arr[i - index] = arr[i];
      arr[i] = 0;
    }
  }
  return arr;
}

264.请实现一个 add 函数,满足以下功能【JavaScript】

题目如下

add(1); 	// 1
add(1)(2);  	// 3
add(1)(2)(3);  // 6
add(1)(2, 3);   // 6
add(1, 2)(3);   // 6
add(1, 2, 3);   // 6

解法

解法1:

function currying(fn, length) {
  length = length || fn.length; 	// 注释 1
  return function(...args) {			// 注释 2
    return args.length >= length	// 注释 3
      ? fn.apply(this, args)			// 注释 4
      : currying(fn.bind(this, ...args), length - args.length) // 注释 5
  }
}

/**
 注释 1:第一次调用获取函数 fn 参数的长度,后续调用获取 fn 剩余参数的长度

 注释 2:currying 包裹之后返回一个新函数,接收参数为 ...args

 注释 3:新函数接收的参数长度是否大于等于 fn 剩余参数需要接收的长度

 注释 4:满足要求,执行 fn 函数,传入新函数的参数

 注释 5:不满足要求,递归 currying 函数,新的 fn 为 bind 返回的新函数(bind 绑定了 ...args 参数,未执行),新的 length 为 fn 剩余参数的长度
 */

解法2

const currying = fn =>
  judge = (...args) =>
    args.length >= fn.length
      ? fn(...args)
      : (...arg) => judge(...args, ...arg)

解法3:

function add() {
  let args = [].slice.call(arguments);
  let fn = function() {
    let fn_args = [].slice.call(arguments)
    return add.apply(null, args.concat(fn_args))
  }
  fn.toString = function() {
    return args.reduce((a, b) => a + b)
  }
  return fn
}

265.react-router 里的标签和标签有什么区别【JavaScript】

从最终渲染的 DOM 来看,这两者都是链接,都是 标签,区别是:

是 react-router 里实现路由跳转的链接,一般配合 使用,react-router 接管了其默认的链接跳转行为,区别于传统的页面跳转, 的“跳转”行为只会触发相匹配的 对应的页面内容更新,而不会刷新整个页面。

而 标签就是普通的超链接了,用于从当前页面跳转到 href 指向的另一个页面(非锚点情况)

源码层面

先看Link点击事件handleClick部分源码

if (_this.props.onClick) _this.props.onClick(event);

if (!event.defaultPrevented && // onClick prevented default
  event.button === 0 && // ignore everything but left clicks
  !_this.props.target && // let browser handle "target=_blank" etc.
  !isModifiedEvent(event) // ignore clicks with modifier keys
) {
  event.preventDefault();

  var history = _this.context.router.history;
  var _this$props = _this.props,
    replace = _this$props.replace,
    to = _this$props.to;


  if (replace) {
    history.replace(to);
  } else {
    history.push(to);
  }
}

Link做了3件事情:

  1. 有onclick那就执行onclick
  2. click的时候阻止a标签默认事件(这样子点击123就不会跳转和刷新页面)
  3. 再取得跳转href(即是to),用history(前端路由两种方式之一,history & hash)跳转,此时只是链接变了,并没有刷新页面

266.实现 convert 方法,把原始 list 转换成树形结构,要求尽可能降低时间复杂度【JavaScript】

题目如下

以下数据结构中,id 代表部门编号,name 是部门名称,parentId 是父部门编号,为 0 代表一级部门,现在要求实现一个 convert 方法,把原始 list 转换成树形结构,parentId 为多少就挂载在该 id 的属性 children 数组下,结构如下:

// 原始 list 如下
let list = [
  { id: 1, name: '部门A', parentId: 0 },
  { id: 2, name: '部门B', parentId: 0 },
  { id: 3, name: '部门C', parentId: 1 },
  { id: 4, name: '部门D', parentId: 1 },
  { id: 5, name: '部门E', parentId: 2 },
  { id: 6, name: '部门F', parentId: 3 },
  { id: 7, name: '部门G', parentId: 2 },
  { id: 8, name: '部门H', parentId: 4 }
];
const result = convert(list);

// 转换后的结果如下
let result = [
  {
    id: 1,
    name: '部门A',
    parentId: 0,
    children: [
      {
        id: 3,
        name: '部门C',
        parentId: 1,
        children: [
          {
            id: 6,
            name: '部门F',
            parentId: 3
          }, {
            id: 16,
            name: '部门L',
            parentId: 3
          }
        ]
      },
      {
        id: 4,
        name: '部门D',
        parentId: 1,
        children: [
          {
            id: 8,
            name: '部门H',
            parentId: 4
          }
        ]
      }
    ]
  },
  ···
]
;

解法

解法1:
大型找爹现场
时间复杂度O(n^2)

function convert(arr) {
  return arr.filter((child) => {
    child.children = arr.filter(item => item.parentId === child.id)
    return child.parentId === 0
  })
}

console.log(convert(list))

解法2:
先遍历出hash表O(n)
再遍历找爹O(n)
时间复杂度:O(2n)=O(n)
大型找爹现场,找到爹就把自己push到爹的房里,如果没有房间先造一个

function convert(arr) {
  const res = []
  const map = arr.reduce((obj, item) => (obj[item.id] = item, obj), {})
  for (let item of arr) {
    if (item.parentId === 0) {
      res.push(item)
      continue
    }
    if (map.hasOwnProperty(item.parentId)) {
      const parent = map[item.parentId]
      parent.children = parent.children || []
      parent.children.push(item)
    }
  }
  return res
}

高级开发者相关问题【共计 3 道题】

257.[React] 为什么不能在循环、条件或嵌套函数中调用 Hooks?【web框架】

如果在条件语句中使用hooks,React会抛出 error。

这与React Hooks的底层设计的数据结构相关,先抛出结论:react用链表来严格保证hooks的顺序

一个典型的useState使用场景:

const [name,setName] = useState('leo');

......

setName('Lily');

那么hooks在这两条语句分别作了什么?

上图是 useState 首次渲染的路径,其中,跟我们问题相关的是 mountState 这个过程,简而言之,这个过程初始化了一个hooks,并且将其追加到链表结尾。

// 进入 mounState 逻辑

function mountState(initialState) {

  // 将新的 hook 对象追加进链表尾部
  var hook = mountWorkInProgressHook();

  // initialState 可以是一个回调,若是回调,则取回调执行后的值

  if (typeof initialState === 'function') {

    // $FlowFixMe: Flow doesn't like mixed types

    initialState = initialState();
  }

  // 创建当前 hook 对象的更新队列,这一步主要是为了能够依序保留 dispatch

  const queue = hook.queue = {

    last: null,

    dispatch: null,

    lastRenderedReducer: basicStateReducer,

    lastRenderedState: (initialState: any),

  };

  // 将 initialState 作为一个“记忆值”存下来

  hook.memoizedState = hook.baseState = initialState;

  // dispatch 是由上下文中一个叫 dispatchAction 的方法创建的,这里不必纠结这个方法具体做了什么

  var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);

  // 返回目标数组,dispatch 其实就是示例中常常见到的 setXXX 这个函数,想不到吧?哈哈

  return [hook.memoizedState, dispatch];
}

从这段源码中我们可以看出,mounState 的主要工作是初始化 Hooks。在整段源码中,最需要关注的是 mountWorkInProgressHook 方法,它为我们道出了 Hooks 背后的数据结构组织形式。以下是 mountWorkInProgressHook 方法的源码:

function mountWorkInProgressHook() {

  // 注意,单个 hook 是以对象的形式存在的
  var hook = {

    memoizedState: null,

    baseState: null,

    baseQueue: null,

    queue: null,

    next: null

  };

  if (workInProgressHook === null) {
    // 这行代码每个 React 版本不太一样,但做的都是同一件事:将 hook 作为链表的头节点处理
    firstWorkInProgressHook = workInProgressHook = hook;
  } else {
    // 若链表不为空,则将 hook 追加到链表尾部
    workInProgressHook = workInProgressHook.next = hook;
  }
  // 返回当前的 hook
  return workInProgressHook;
}

到这里可以看出,hook 相关的所有信息收敛在一个 hook 对象里,而 hook 对象之间以单向链表的形式相互串联。

接着,我们来看更新过程

上图中,需要注意的是updateState的过程:按顺序去遍历之前构建好的链表,取出对应的数据信息进行渲染。

我们把 mountState 和 updateState 做的事情放在一起来看:mountState(首次渲染)构建链表并渲染;updateState 依次遍历链表并渲染。

hooks 的渲染是通过“依次遍历”来定位每个 hooks 内容的。如果前后两次读到的链表在顺序上出现差异,那么渲染的结果自然是不可控的。

这个现象有点像我们构建了一个长度确定的数组,数组中的每个坑位都对应着一块确切的信息,后续每次从数组里取值的时候,只能够通过索引(也就是位置)来定位数据。也正因为如此,在许多文章里,都会直截了当地下这样的定义:Hooks 的本质就是数组。但读完这一课时的内容你就会知道,Hooks 的本质其实是链表。

我们举个例子:

    let mounted = false;

if(!mounted){
  // eslint-disable-next-line
  const [name,setName] = useState('leo');
  const [age,setAge] = useState(18);
  mounted = true;
}
const [career,setCareer] = useState('码农');
console.log('career',career);

......

setName('Lily')}>
  点我点我点我

点击p后,我们期望的输出是 "码农",然而事实上(尽管会error,但是打印还是执行)打印的为 "Lily"

原因是,三个useState在初始化的时候已经构建好了一个三个节点的链表结构,依次为: name('leo') --> age(18) --> career('码农')

每个节点都已经派发了一个与之对应的update操作,因此执行setName时候,三个节点就修改为了 name('Lily') --> age(18) --> career('码农')

然后执行update渲染操作,从链表依次取出值,此时,条件语句的不再执行,第一个取值操作会从链表的第一个,也就是name对应的hooks对象进行取值:此时取到的为 name:Lily

必须按照顺序调用从根本上来说是因为 useState 这个钩子在设计层面并没有“状态命名”这个动作,也就是说你每生成一个新的状态,React 并不知道这个状态名字叫啥,所以需要通过顺序来索引到对应的状态值

259.为什么普通 for 循环的性能远远高于 forEach 的性能?【JavaScript】

首先问题说"for循环优于forEach"并不完全正确

循环次数不够多的时候, forEach 性能优于 for

// 循环十万次
let arrs = new Array(100000);

console.time('for');
for (let i = 0; i < arrs.length; i++) {};
console.timeEnd('for'); // for: 2.36474609375 ms

console.time('forEach');
arrs.forEach((arr) => {});
console.timeEnd('forEach'); // forEach: 0.825927734375 ms

循环次数越大, for 的性能优势越明显

// 循环 1 亿次
let arrs = new Array(100000000);

console.time('for');
for (let i = 0; i < arrs.length; i++) {};
console.timeEnd('for'); // for: 72.7099609375 ms

console.time('forEach');
arrs.forEach((arr) => {});
console.timeEnd('forEach'); // forEach: 923.77392578125 ms

先做一下对比

对比类型

for

forEach

遍历

for循环按顺序遍历

forEach 使用 iterator 迭代器遍历

数据结构

for循环是随机访问元素

forEach 是顺序链表访问元素

性能上

对于arraylist,是顺序表,使用for循环可以顺序访问,速度较快;使用foreach会比for循环稍慢一些

对于linkedlist,是单链表,使用for循环每次都要从第一个元素读取next域来读取,速度非常慢;使用foreach可以直接读取当前结点,数据较快

结论

for 性能优于 forEach , 主要原因如下:

  1. foreach相对于for循环,代码减少了,但是foreach依赖IEnumerable。在运行的时候效率低于for循环。
  2. for循环没有额外的函数调用栈和上下文,所以它的实现最为简单。forEach:对于forEach来说,它的函数签名中包含了参数和上下文,所以性能会低于 for 循环。

参考文档

261.[Vue] 使用Proxy实现简易的vue双向数据绑定【web框架】【出题公司: 腾讯】

proxy 的基本使用

可以直接看这个链接: #8

使用proxy实现数据劫持

let data = {
    name: YoLinDeng,
    height: '176cm'
}

const p = new Proxy(data, {
    get(target, prop) {
        return Reflect.get(...arguments)
    },
    set(target, prop, newValue) {
        return Reflect.set(...arguments)
    }
})

关于vue中数据响应式的原理

对数据进行侦测

对模板字符串进行编译

虚拟dom

实现简易的vue双向数据绑定

vue的双向数据绑定主要是指,数据变化更新视图变化,视图变化更新数据。

实现代码如下




  
  
  Document
  


  
    {{name}}
    {{message}}
    
    {{test}}
  
  

class vue extends EventTarget {
  constructor(option) {
    super()
    this.option = option
    this._data = this.option.data
    this.el = document.querySelector(this.option.el)
    this.compileNode(this.el)
    this.observe(this._data)
  }
  // 实现监听器方法
  observe(data) {
    const context = this
    // 使用proxy代理,劫持数据
    this._data = new Proxy(data, {
      set(target, prop, newValue) {
        // 自定义事件
        let event = new CustomEvent(prop, {
          detail: newValue
        })
        // 发布自定义事件
        context.dispatchEvent(event)
        return Reflect.set(...arguments)
      }
    })
  }
  // 实现解析器方法,解析模板
  compileNode(el) {
    let child = el.childNodes
    let childArr = [...child]
    childArr.forEach(node => {
      if (node.nodeType === 3) {
        let text = node.textContent
        let reg = /{{s*([^s{}]+)s*}}/g
        if (reg.test(text)) {
          let $1 = RegExp.$1
          this._data[$1] && (node.textContent = text.replace(reg, this._data[$1]))
          // 监听数据更改事件
          this.addEventListener($1, e => {
            node.textContent = text.replace(reg, e.detail)
          })
        }
      } else if (node.nodeType === 1) { // 如果是元素节点
        let attr = node.attributes
        // 判断属性中是否含有v-model
        if (attr.hasOwnProperty('v-model')) {
          let keyName = attr['v-model'].nodeValue
          node.value = this._data[keyName]
          node.addEventListener('input', e => {
            this._data[keyName] = node.value
          })
        }
        // 递归调用解析器方法
        this.compileNode(node)
      }
    })
  }
}

资深开发者相关问题【共计 1 道题】

267.[Webpack] webpack热更新原理是什么?【工程化】

Hot Module Replacement,简称HMR,无需完全刷新整个页面的同时,更新模块。HMR的好处,在日常开发工作中体会颇深:节省宝贵的开发时间、提升开发体验

刷新我们一般分为两种:

HMR作为一个Webpack内置的功能,可以通过HotModuleReplacementPlugin或--hot开启。那么,HMR到底是怎么实现热更新的呢?下面让我们来了解一下吧!

1. webpack-dev-server启动本地服务

我们根据webpack-dev-server的package.json中的bin命令,可以找到命令的入口文件bin/webpack-dev-server.js。

// node_modules/webpack-dev-server/bin/webpack-dev-server.js

// 生成webpack编译主引擎 compiler
let compiler = webpack(config);

// 启动本地服务
let server = new Server(compiler, options, log);
server.listen(options.port, options.host, (err) => {
    if (err) {throw err};
});

本地服务代码:

// node_modules/webpack-dev-server/lib/Server.js
class Server {
    constructor() {
        this.setupApp();
        this.createServer();
    }

    setupApp() {
        // 依赖了express
    	this.app = new express();
    }

    createServer() {
        this.listeningApp = http.createServer(this.app);
    }
    listen(port, hostname, fn) {
        return this.listeningApp.listen(port, hostname, (err) => {
            // 启动express服务后,启动websocket服务
            this.createSocketServer();
        }
    }
}

这一小节代码主要做了三件事:

上述代码主要干了三件事,但是源码在启动服务前又做了很多事,接下来便看看webpack-dev-server/lib/Server.js还做了哪些事?

2. 修改webpack.config.js的entry配置

启动本地服务前,调用了updateCompiler(this.compiler)方法。这个方法中有 2 段关键性代码。一个是获取websocket客户端代码路径,另一个是根据配置获取webpack热更新代码路径。

// 获取websocket客户端代码
const clientEntry = `${require.resolve(
    '../../client/'
)}?${domain}${sockHost}${sockPath}${sockPort}`;

// 根据配置获取热更新代码
let hotEntry;
if (options.hotOnly) {
    hotEntry = require.resolve('webpack/hot/only-dev-server');
} else if (options.hot) {
    hotEntry = require.resolve('webpack/hot/dev-server');
}

修改后的webpack入口配置如下:

// 修改后的entry入口
{ entry:
    { index:
        [
            // 上面获取的clientEntry
            'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080',
            // 上面获取的hotEntry
            'xxx/node_modules/webpack/hot/dev-server.js',
            // 开发配置的入口
            './src/index.js'
    	],
    },
}      

为什么要新增了 2 个文件?在入口默默增加了 2 个文件,那就意味会一同打包到bundle文件中去,也就是线上运行时。

(1)webpack-dev-server/client/index.js

首先这个文件用于websocket的,因为websoket是双向通信,如果不了解websocket,建议简单了解一下websocket速成。我们在第 1 步 webpack-dev-server初始化 的过程中,启动的是本地服务端的websocket。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?总不能让开发者去写吧hhhhhh。因此我们需要把websocket客户端通信代码偷偷塞到我们的代码中。客户端具体的代码后面会在合适的时机细讲哦。

(2)webpack/hot/dev-server.js

这个文件主要是用于检查更新逻辑的,这里大家知道就好,代码后面会在合适的时机(第5步)细讲。

3. 监听webpack编译结束

修改好入口配置后,又调用了setupHooks方法。这个方法是用来注册监听事件的,监听每次webpack编译完成。

// node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
    const {done} = compiler.hooks;
    // 监听webpack的done钩子,tapable提供的监听方法
    done.tap('webpack-dev-server', (stats) => {
        this._sendStats(this.sockets, this.getStats(stats));
        this._stats = stats;
    });
};

当监听到一次webpack编译结束,就会调用_sendStats方法通过websocket给浏览器发送通知,ok和hash事件,这样浏览器就可以拿到最新的hash值了,做检查更新逻辑。

// 通过websoket给客户端发消息
_sendStats() {
    this.sockWrite(sockets, 'hash', stats.hash);
    this.sockWrite(sockets, 'ok');
}

4. webpack监听文件变化

每次修改代码,就会触发编译。说明我们还需要监听本地代码的变化,主要是通过setupDevMiddleware方法实现的。

这个方法主要执行了webpack-dev-middleware库。很多人分不清webpack-dev-middleware和webpack-dev-server的区别。其实就是因为webpack-dev-server只负责启动服务和前置准备工作,所有文件相关的操作都抽离到webpack-dev-middleware库了,主要是本地文件的编译输出以及监听,无非就是职责的划分更清晰了。

那我们来看下webpack-dev-middleware源码里做了什么事:

// node_modules/webpack-dev-middleware/index.js
compiler.watch(options.watchOptions, (err) => {
    if (err) { /*错误处理*/ }
});

// 通过“memory-fs”库将打包后的文件写入内存
setFs(context, compiler); 

(1)调用了compiler.watch方法,在第 1 步中也提到过,compiler的强大。这个方法主要就做了 2 件事:

为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译就归功于compiler.watch这个方法了。监听本地文件的变化主要是通过文件的生成时间是否有变化,这里就不细讲了。

(2)执行setFs方法,这个方法主要目的就是将编译后的文件打包到内存。这就是为什么在开发的过程中,你会发现dist目录没有打包后的代码,因为都在内存中。原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs。

5. 浏览器接收到热更新的通知

我们已经可以监听到文件的变化了,当文件发生变化,就触发重新编译。同时还监听了每次编译结束的事件。当监听到一次webpack编译结束,_sendStats方法就通过websoket给浏览器发送通知,检查下是否需要热更新。下面重点讲的就是_sendStats方法中的ok和hash事件都做了什么。

那浏览器是如何接收到websocket的消息呢?回忆下第 2 步骤增加的入口文件,也就是websocket客户端代码。

'xxx/node_modules/webpack-dev-server/client/index.js?http://localhost:8080'

这个文件的代码会被打包到bundle.js中,运行在浏览器中。来看下这个文件的核心代码吧。

// webpack-dev-server/client/index.js
var socket = require('./socket');
var onSocketMessage = {
    hash: function hash(_hash) {
        // 更新currentHash值
        status.currentHash = _hash;
    },
    ok: function ok() {
        sendMessage('Ok');
        // 进行更新检查等操作
        reloadApp(options, status);
    },
};
// 连接服务地址socketUrl,?http://localhost:8080,本地服务地址
socket(socketUrl, onSocketMessage);

function reloadApp() {
	if (hot) {
        log.info('[WDS] App hot update...');

        // hotEmitter其实就是EventEmitter的实例
        var hotEmitter = require('webpack/hot/emitter');
        hotEmitter.emit('webpackHotUpdate', currentHash);
    }
}

socket方法建立了websocket和服务端的连接,并注册了 2 个监听事件。

热更新检查事件是调用reloadApp方法。比较奇怪的是,这个方法又利用node.js的EventEmitter,发出webpackHotUpdate消息。这是为什么?为什么不直接进行检查更新呢?

个人理解就是为了更好的维护代码,以及职责划分的更明确。websocket仅仅用于客户端(浏览器)和服务端进行通信。而真正做事情的活还是交回给了webpack。

那webpack怎么做的呢?再来回忆下第 2 步。入口文件还有一个文件没有讲到,就是:

'xxx/node_modules/webpack/hot/dev-server.js'

这个文件的代码同样会被打包到bundle.js中,运行在浏览器中。这个文件做了什么就显而易见了吧!先瞄一眼代码:

// node_modules/webpack/hot/dev-server.js
var check = function check() {
    module.hot.check(true)
        .then(function(updatedModules) {
            // 容错,直接刷新页面
            if (!updatedModules) {
                window.location.reload();
                return;
            }

            // 热更新结束,打印信息
            if (upToDate()) {
                log("info", "[HMR] App is up to date.");
            }
    })
        .catch(function(err) {
            window.location.reload();
        });
};

var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function(currentHash) {
    lastHash = currentHash;
    check();
});

这里webpack监听到了webpackHotUpdate事件,并获取最新了最新的hash值,然后终于进行检查更新了。检查更新呢调用的是module.hot.check方法。那么问题又来了,module.hot.check又是哪里冒出来了的!答案是HotModuleReplacementPlugin搞得鬼。这里留个疑问,继续往下看。

6. HotModuleReplacementPlugin

前面好像一直是webpack-dev-server做的事,那HotModuleReplacementPlugin在热更新过程中又做了什么伟大的事业呢?

首先你可以对比下,配置热更新和不配置时bundle.js的区别。内存中看不到?直接执行webpack命令就可以看到生成的bundle.js文件啦。不要用webpack-dev-server启动就好了。

(1)没有配置的。

(2)配置了HotModuleReplacementPlugin或--hot的。

哦~ 我们发现moudle新增了一个属性为hot,再看hotCreateModule方法。 这不就找到module.hot.check是哪里冒出来的。

经过对比打包后的文件,__webpack_require__中的moudle以及代码行数的不同。我们都可以发现HotModuleReplacementPlugin原来也是默默的塞了很多代码到bundle.js中呀。这和第 2 步骤很是相似哦!为什么,因为检查更新是在浏览器中操作呀。这些代码必须在运行时的环境。

你也可以直接看浏览器Sources下的代码,会发现webpack和plugin偷偷加的代码都在哦。在这里调试也很方便。

HotModuleReplacementPlugin如何做到的?这里我就不讲了,因为这需要你对tapable以及plugin机制有一定了解,可以看下我写的文章Webpack插件机制之Tapable-源码解析。当然你也可以选择跳过,只关心热更新机制即可,毕竟信息量太大。

7. moudle.hot.check 开始热更新

通过第 6 步,我们就可以知道moudle.hot.check方法是如何来的啦。那都做了什么?之后的源码都是HotModuleReplacementPlugin塞入到bundle.js中的哦,我就不写文件路径了。

hotAvailableFilesMap = update.c; // 需要更新的文件
hotUpdateNewHash = update.h; // 更新下次热更新hash值
hotSetStatus("prepare"); // 进入热更新准备状态
function hotDownloadUpdateChunk(chunkId) {
    var script = document.createElement("script");
    script.charset = "utf-8";
    script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
    if (null) script.crossOrigin = null;
    document.head.appendChild(script);
 }

这个函数体为什么要单独拿出来,因为这里要解释下为什么使用JSONP获取最新代码?主要是因为JSONP获取的代码可以直接执行。为什么要直接执行?我们来回忆下/hash.hot-update.js的代码格式是怎么样的。

可以发现,新编译后的代码是在一个webpackHotUpdate函数体内部的。也就是要立即执行webpackHotUpdate这个方法。

再看下webpackHotUpdate这个方法。

window["webpackHotUpdate"] = function (chunkId, moreModules) {
    hotAddUpdateChunk(chunkId, moreModules);
} ;
function hotAddUpdateChunk(chunkId, moreModules) {
    // 更新的模块moreModules赋值给全局全量hotUpdate
    for (var moduleId in moreModules) {
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
	    hotUpdate[moduleId] = moreModules[moduleId];
        }
    }
    // 调用hotApply进行模块的替换
    hotUpdateDownloaded();
}

8. hotApply 热更新模块替换

热更新的核心逻辑就在hotApply方法了。 hotApply代码有将近 400 行,还是挑重点讲了,看哭

①删除过期的模块,就是需要替换的模块

通过hotUpdate可以找到旧模块

var queue = outdatedModules.slice();
while (queue.length > 0) {
    moduleId = queue.pop();
    // 从缓存中删除过期的模块
    module = installedModules[moduleId];
    // 删除过期的依赖
    delete outdatedDependencies[moduleId];

    // 存储了被删掉的模块id,便于更新代码
    outdatedSelfAcceptedModules.push({
        module: moduleId
    });
}

②将新的模块添加到 modules 中

appliedUpdate[moduleId] = hotUpdate[moduleId];
for (moduleId in appliedUpdate) {
    if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
        modules[moduleId] = appliedUpdate[moduleId];
    }
}

③通过__webpack_require__执行相关模块的代码

for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
    var item = outdatedSelfAcceptedModules[i];
    moduleId = item.module;
    try {
        // 执行最新的代码
        __webpack_require__(moduleId);
    } catch (err) {
        // ...容错处理
    }
}

hotApply的确比较复杂,知道大概流程就好了,这一小节,要求你对webpack打包后的文件如何执行的有一些了解,大家可以自去看下。

总结

还是以阅读源码的形式画的图,①-④的小标记,是文件发生变化的一个流程。

参考文档

展开阅读全文

页面更新:2024-06-08

标签:数组   节点   容器   函数   布局   元素   代码   文件   方法   数据

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top