2023.03.14 - 2023.03.24 更新前端面试问题总结(45道题) - 下部

发布于:2023-03-26 ⋅ 阅读:(589) ⋅ 点赞:(0)
2023.03.14 - 2023.03.24 更新前端面试问题总结(45道题) - 下部
获取更多面试问题可以访问
github 地址: https://github.com/pro-collection/interview-question/issues
gitee 地址: https://gitee.com/yanleweb/interview-question/issues

目录:

  • 初级开发者相关问题【共计 1 道题】

    • 111.null 和 undefined 的区别,如何让一个属性变为 null?【JavaScript】
  • 中级开发者相关问题【共计 20 道题】

    • 73.express middleware(中间件) 工作原理是什么??【Nodejs】
    • 104.说一说 cookie sessionStorage localStorage 区别?【JavaScript】
    • 105.promise.race、promise.all、promise.allSettled 有哪些区别?【JavaScript】
    • 106.手写代码实现 promise.race【JavaScript】
    • 109.JavaScript 有几种方法判断变量的类型?【JavaScript】
    • 110.样式优先级的规则是什么?【CSS】
    • 115.Proxy 和 Object.defineProperty 的区别是啥?【JavaScript】
    • 117.css 中 三栏布局的实现方案 的实现方案有哪些?【CSS】
    • 119.vue 的 keep-alive 的原理是啥?【web框架】
    • 125.当使用 new 关键字创建对象时, 会经历哪些步骤?【JavaScript】
    • 126.es5 和 es6 使用 new 关键字实例化对象的流程是一样的吗?【JavaScript】
    • 127.如何实现可过期的 localstorage 数据?【JavaScript】
    • 132.React setState 是同步还是异步的?【web框架】
    • 133.react 18 版本中 setState 是同步还是异步的?【web框架】
    • 134.【React】合成事件了解多少【web框架】
    • 135.【React】绑定事件的原理是什么?【web框架】
    • 139.pnpm 和 npm 的区别?【工程化】
    • 142.事件循环原理?【JavaScript】
    • 143.[vue] 双向数据绑定原理?【web框架】
    • 146.nodejs 进程间如何通信?【Nodejs】
  • 高级开发者相关问题【共计 22 道题】

    • 77.虚拟 dom 原理是啥,手写一个简单的虚拟 dom 实现?【JavaScript】
    • 107.手写代码实现 promise.all【JavaScript】
    • 108.手写实现 Promise.allSettled【JavaScript】
    • 112.CSS 尺寸单位有哪些?【CSS】
    • 113.React Router 中 HashRouter 和 BrowserRouter 的区别和原理?【web框架】
    • 114.Vue3.0 实现数据双向绑定的方法是什么?【web框架】
    • 118.浏览器垃圾回收机制?【浏览器】
    • 120.常见的 web 前端网路攻击有哪些?【网络】
    • 121.如何防止 跨站脚本攻击(Cross-Site Scripting, XSS)?【网络】
    • 122.跨站请求伪造(Cross-Site Request Forgery, CSRF)具体实现步骤是啥, 如何防止?【网络】
    • 123.script 标签 defer 和 async 区别?【浏览器】
    • 124.Vue 中 $nextTick 作用与原理是啥?【web框架】
    • 128.axios的拦截器原理及应用、简单手写核心逻辑?【web框架】
    • 129.有什么方法可以保持前后端实时通信?【网络】
    • 130.react 遍历渲染节点列表, 为什么要加 key ?【web框架】
    • 131.react lazy import 实现懒加载的原理是什么?【web框架】
    • 136.如何分析页面加载慢?【工程化】
    • 137.【性能】以用户为中心的前端性能指标有哪些?【工程化】
    • 138.浏览器渲染进程了解多少?【浏览器】
    • 140.pnpm 了解多少?【工程化】
    • 141.如何组织 monorepo 工程?【工程化】
    • 144.[vue] 是怎么解析template的?【web框架】
  • 资深开发者相关问题【共计 2 道题】

    • 116.React Diff算法是怎么实现的?【JavaScript】
    • 145.实现 JS 沙盒的方式有哪些?【工程化】

121.如何防止 跨站脚本攻击(Cross-Site Scripting, XSS)?【网络】

以下是一些防范跨站脚本攻击的常见方法:

  1. 输入过滤:对于所有输入的数据(如表单数据、URL 参数等),应该进行过滤和验证。特别是对于敏感数据(如密码、信用卡信息等),应该进行严格的验证,防止恶意的脚本注入。可以使用一些开源的输入验证工具,如OWASP ESAPI来过滤恶意输入。
  2. 对特殊字符进行转义:对于所有输出到页面上的数据,应该对特殊字符进行转义,比如将 < 转义为 <、将 > 转义为 > 等。这可以防止攻击者通过在页面上注入恶意的脚本。
  3. CSP(Content Security Policy):CSP是一种浏览器安全机制,可以限制 Web 页面可以加载哪些资源。通过设置合适的 CSP,可以防止恶意脚本的注入。
  4. HttpOnly Cookie:通过设置 HttpOnly 标志,可以防止脚本访问 Cookie。这可以防止攻击者窃取用户的身份验证信息。
  5. 随机化 Session ID:在用户登录后,应该为其分配一个随机化的 Session ID,防止攻击者通过猜测 Session ID 来劫持用户会话。
  6. 使用安全的编程语言和框架:使用安全的编程语言和框架可以降低跨站脚本攻击的风险。比如使用最新的版本的编程语言和框架,以获得更好的安全性。

需要注意的是,防范跨站脚本攻击需要综合多种方法,单一的措施并不能完全防止攻击。此外,开发人员应该始终关注最新的安全漏洞和攻击技术,及时采取相应的防范措施。

122.跨站请求伪造(Cross-Site Request Forgery, CSRF)具体实现步骤是啥, 如何防止?【网络】

跨站请求伪造(Cross-Site Request Forgery, CSRF)是一种常见的网络攻击方式,攻击者可以利用已登录的用户身份,通过伪造用户的请求,对服务器上的资源进行非法操作。下面是一种常见的 CSRF 攻击方式:

  1. 用户在浏览器中登录了某个网站,并获取了该网站的 Cookie。
  2. 攻击者诱导用户访问一个恶意网站,并在该网站上放置了一段恶意代码,用于发起 CSRF 攻击。
  3. 当用户在恶意网站上执行某个操作时,比如点击某个按钮或链接,恶意代码会自动向目标网站发送一个 HTTP 请求,请求中包含攻击者想要执行的操作和参数,同时也会携带用户的 Cookie。
  4. 目标网站接收到请求后,会认为这是一个合法的请求,因为它携带了用户的 Cookie。于是服务器会执行攻击者想要的操作,比如删除用户的数据、修改用户的密码等。

为了防止 CSRF 攻击,开发人员可以采取以下措施:

  1. 随机化 Token:为每个请求生成一个随机化的 Token,将 Token 放入表单中,并在服务器端进行验证。这可以防止攻击者伪造合法的请求。
  2. 使用 Referer 验证:在服务器端进行 Referer 验证,只允许来自合法来源的请求。这可以防止攻击者在自己的网站上放置恶意代码,进行 CSRF 攻击。
  3. 使用验证码:在某些敏感操作上,比如修改密码、删除数据等,可以要求用户输入验证码。这可以降低攻击者的成功率,因为攻击者很难获取验证码。

需要注意的是,以上措施并不能完全防止 CSRF 攻击,因为攻击者总是可以通过一些复杂的方法来规避这些防御措施。因此,开发人员需要综合考虑多种防范措施,以提高网站的安全性。

123.script 标签 defer 和 async 区别?【浏览器】

deferasync 是用于控制脚本加载和执行的 HTML <script> 标签属性。

deferasync 的主要区别在于它们对脚本的加载和执行的影响。

  • defer 属性告诉浏览器立即下载脚本,但延迟执行,等到文档加载完成后再按照它们在页面中出现的顺序依次执行。这意味着脚本不会阻止文档的解析和渲染,并且它们也不会阻止其他脚本的执行。如果多个脚本都使用 defer 属性,则它们将按照它们在页面中出现的顺序依次执行。
  • async 属性告诉浏览器立即下载脚本,但它们不一定按照它们在页面中出现的顺序执行。它们将在下载完成后立即执行。这意味着脚本不会阻止文档的解析和渲染,但可能会阻止其他脚本的执行。如果多个脚本都使用 async 属性,则它们将按照它们下载完成的顺序依次执行。

需要注意的是,当使用 deferasync 属性时,浏览器的支持情况可能不同。一些较旧的浏览器可能不支持这些属性,或者仅支持 defer 而不支持 async。因此,为了确保脚本的兼容性,建议在使用 deferasync 属性时,同时提供一个备用脚本,并考虑使用特性检测来检查浏览器是否支持这些属性。

124.Vue 中 $nextTick 作用与原理是啥?【web框架】

$nextTick 是 Vue.js 提供的一个实例方法,用于在 DOM 更新之后执行一些操作。具体来说,它会将回调函数推迟到下次 DOM 更新循环之后执行。

在 Vue 中,数据变化时,Vue 会异步执行视图更新。例如,当一个数据变化时,Vue 会将这个变化包装成一个更新任务,并将其推入更新队列。Vue 会在下一个事件循环周期中遍历这个队列,并依次执行更新任务,最终将视图更新为最新状态。

在某些情况下,我们需要在 DOM 更新之后执行一些操作,例如在 Vue 中更新 DOM 后获取更新后的元素尺寸、在 Vue 组件中调用子组件的方法等等。如果直接在数据变化后立即执行这些操作,可能会遇到一些问题,例如元素尺寸并未更新,子组件尚未完全挂载等等。这时候,就需要使用 $nextTick 方法。

$nextTick 的实现原理是利用了 JavaScript 的事件循环机制。具体来说,当调用 $nextTick 方法时,Vue 会将回调函数推入一个回调队列中。在下一个事件循环周期中,Vue 会遍历这个回调队列,并依次执行其中的回调函数。由于在这个时候 DOM 已经完成了更新,因此可以安全地执行需要在 DOM 更新之后进行的操作。

需要注意的是,$nextTick 是异步执行的,因此不能保证回调函数会立即执行。如果需要等待 $nextTick 的回调函数执行完毕后再继续执行某些操作,可以使用 Promise 或 async/await 来等待异步操作的完成。

128.axios的拦截器原理及应用、简单手写核心逻辑?【web框架】

axios 拦截器的使用

Axios 是一个基于 Promise 的 HTTP 客户端库,可以用于浏览器和 Node.js 环境中发送 HTTP 请求。Axios 提供了拦截器机制,可以在请求发送前和响应返回后对请求和响应进行拦截和处理,从而实现一些通用的功能,例如:添加请求头、添加认证信息、显示 loading 状态、错误处理等。

Axios 的拦截器机制主要是通过 interceptors 属性来实现的,该属性包含了 requestresponse 两个对象,分别代表请求拦截器和响应拦截器。每个对象都包含 use 方法,该方法用于注册拦截器回调函数,拦截器回调函数会在请求发送前或响应返回后被调用。

下面是一个示例代码,展示了如何使用 Axios 的拦截器:

import axios from 'axios'

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
  // 在发送请求之前做些什么
  console.log('请求拦截器')
  return config
}, function (error) {
  // 对请求错误做些什么
  return Promise.reject(error)
})

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
  // 对响应数据做点什么
  console.log('响应拦截器')
  return response
}, function (error) {
  // 对响应错误做点什么
  return Promise.reject(error)
})

// 发送请求
axios.get('/api/user')
  .then(function (response) {
    // 处理响应数据
  })
  .catch(function (error) {
    // 处理请求错误
  })

在上面的代码中,我们首先通过 import 语句引入了 Axios 库。然后,我们调用 axios.interceptors.request.use 方法注册了一个请求拦截器回调函数,该函数会在发送请求前被调用,可以在该函数中进行一些通用的操作,例如添加请求头、添加认证信息等。接着,我们调用 axios.interceptors.response.use 方法注册了一个响应拦截器回调函数,该函数会在响应返回后被调用,可以在该函数中进行一些通用的操作,例如显示 loading 状态、错误处理等。

最后,我们使用 axios.get 方法发送请求,并通过 thencatch 方法处理响应数据和请求错误。在请求发送前和响应返回后,我们注册的拦截器回调函数会被自动调用,可以对请求和响应进行拦截和处理。

Axios 的拦截器机制非常强大,可以用于实现一些通用的功能,例如添加请求头、添加认证信息、显示 loading 状态、错误处理等。在实际开发中,我们经常会使用 Axios 的拦截器来提高代码的复用性和可维护性。

axios 拦截器原理

Axios 的拦截器机制是通过 interceptors 属性来实现的,该属性包含了 requestresponse 两个对象,分别代表请求拦截器和响应拦截器。每个对象都包含 use 方法,该方法用于注册拦截器回调函数,拦截器回调函数会在请求发送前或响应返回后被调用。

具体来说,当我们使用 axios 发送请求时,会先调用请求拦截器的回调函数,该函数会在请求发送前被调用,可以在该函数中进行一些通用的操作,例如添加请求头、添加认证信息等。如果请求拦截器返回的不是一个 Promise 对象,则会自动将其封装为一个 Promise 对象。

接着,Axios 会使用 XMLHTTPRequest 对象发送请求,并监听其状态变化事件。当响应返回后,Axios 会调用响应拦截器的回调函数,该函数会在响应返回后被调用,可以在该函数中进行一些通用的操作,例如显示 loading 状态、错误处理等。如果响应拦截器返回的不是一个 Promise 对象,则会自动将其封装为一个 Promise 对象。

需要注意的是,Axios 的拦截器是按照添加顺序依次执行的,也就是说,先添加的拦截器回调函数先执行,后添加的拦截器回调函数后执行。如果一个拦截器回调函数中没有调用 next 方法,则后面的拦截器回调函数将不会被执行。

下面是一个示例代码,展示了如何使用 Axios 的拦截器:

import axios from 'axios'

// 添加请求拦截器
axios.interceptors.request.use(function (config) {
  // 在发送请求之前做些什么
  console.log('请求拦截器')
  return config
}, function (error) {
  // 对请求错误做些什么
  return Promise.reject(error)
})

// 添加响应拦截器
axios.interceptors.response.use(function (response) {
  // 对响应数据做点什么
  console.log('响应拦截器')
  return response
}, function (error) {
  // 对响应错误做点什么
  return Promise.reject(error)
})

// 发送请求
axios.get('/api/user')
  .then(function (response) {
    // 处理响应数据
  })
  .catch(function (error) {
    // 处理请求错误
  })

在上面的代码中,我们首先通过 import 语句引入了 Axios 库。然后,我们调用 axios.interceptors.request.use 方法注册了一个请求拦截器回调函数,该函数会在发送请求前被调用,可以在该函数中进行一些通用的操作,例如添加请求头、添加认证信息等。接着,我们调用 axios.interceptors.response.use 方法注册了一个响应拦

axios 拦截器核心逻辑代码实现

下面是一个简单实现 Axios 拦截器核心逻辑的示例代码:

class Axios {
  constructor() {
    // 请求拦截器
    this.requestInterceptors = []
    // 响应拦截器
    this.responseInterceptors = []
  }

  // 注册请求拦截器
  useRequestInterceptor(callback) {
    this.requestInterceptors.push(callback)
  }

  // 注册响应拦截器
  useResponseInterceptor(callback) {
    this.responseInterceptors.push(callback)
  }

  // 发送请求
  async request(config) {
    // 执行请求拦截器
    for (const interceptor of this.requestInterceptors) {
      config = await interceptor(config)
    }

    // 发送请求
    const response = await fetch(config.url, {
      method: config.method,
      headers: config.headers,
      body: config.data
    })

    // 执行响应拦截器
    for (const interceptor of this.responseInterceptors) {
      response = await interceptor(response)
    }

    return response
  }
}

// 创建 Axios 实例
const axios = new Axios()

// 注册请求拦截器
axios.useRequestInterceptor(config => {
  // 在请求头中添加认证信息
  config.headers['Authorization'] = 'Bearer xxx'
  return config
})

// 注册响应拦截器
axios.useResponseInterceptor(response => {
  // 处理响应数据
  return response.json()
})

// 发送请求
axios.request({
  url: '/api/user',
  method: 'GET'
}).then(data => {
  // 处理响应数据
  console.log(data)
}).catch(error => {
  // 处理请求错误
  console.error(error)
})

在上面的代码中,我们首先定义了一个 Axios 类,该类包含了请求拦截器和响应拦截器两个属性,分别用于保存注册的拦截器回调函数。然后,我们定义了 useRequestInterceptoruseResponseInterceptor 两个方法,用于注册请求拦截器和响应拦截器回调函数。在这两个方法中,我们将回调函数保存到对应的属性中。

接着,我们定义了 request 方法,该方法用于发送请求。在 request 方法中,我们首先执行请求拦截器回调函数,将请求配置传递给回调函数,并将回调函数返回的结果赋值给请求配置。接着,我们使用 fetch 函数发送请求,并将响应保存到 response 变量中。然后,我们执行响应拦截器回调函数,将响应对象传递给回调函数,并将回调函数返回的结果赋值给响应对象。最后,我们返回响应对象。

在最后几行代码中,我们创建了一个 axios 实例,并使用 useRequestInterceptor 方法和 useResponseInterceptor 方法注册了请求拦截器和响应拦截器回调函数。然后,我们调用 request 方法发送请求,并使用 then 方法处理响应数据,使用 catch 方法处理请求错误。

129.有什么方法可以保持前后端实时通信?【网络】

实时通信是一种双向的通信方式,前后端都能实时地获取对方的数据和状态变化,目前主要有以下几种方法可以实现:

  1. WebSocket:WebSocket 是一种基于 TCP 协议的双向通信协议,它可以在客户端和服务器之间建立持久性的连接,并且支持服务器主动向客户端推送数据。WebSocket 协议通过 HTTP 协议的 101 状态码进行握手,握手成功后,客户端和服务器之间的通信就不再使用 HTTP 协议,而是使用 WebSocket 协议。WebSocket 协议具有低延迟、高效、实时等优点,适用于实时通信、在线游戏、股票行情等场景。
  2. Server-Sent Events(SSE):SSE 是一种基于 HTTP 协议的服务器推送技术,它允许服务器向客户端推送文本数据或事件数据,而无需客户端发起请求。SSE 协议通过 HTTP 的长连接机制实现服务器向客户端的推送,客户端通过 EventSource API 接口接收服务器推送的数据。SSE 协议比较简单,实现也比较容易,适用于需要推送数据而不需要客户端与服务器进行双向通信的场景。
  3. 长轮询(Long Polling):长轮询是一种基于 HTTP 协议的服务器推送技术,它通过客户端向服务器发送一个长时间的请求,服务器在有数据更新时返回响应,否则将一直等待一段时间后才返回响应。客户端收到响应后立即发起下一次请求。长轮询比较容易实现,适用于需要实时通知客户端数据变化但不需要高实时性的场景。
  4. WebRTC:WebRTC 是一种实时通信协议,它基于 P2P 技术,可以在浏览器之间直接建立通信,并实现视频、音频、数据等多媒体的实时传输。WebRTC 协议支持点对点通信,不需要经过服务器转发,因此具有低延迟、高效、实时等优点,适用于实时视频、音频等场景。

总的来说,WebSocket 和 SSE 协议适用于需要服务器主动向客户端推送数据的场景,长轮询适用于需要实时通知客户端数据变化但不需要高实时性的场景,WebRTC 协议适用于实时视频、音频等场景。选择哪种方法要根据具体的业务场景和需求来决定。

130.react 遍历渲染节点列表, 为什么要加 key ?【web框架】

在 React 中,当我们使用数组渲染节点列表时,通常需要给每个节点添加一个 key 属性,这是因为 React 需要通过 key 属性来判断是否需要更新某个节点,从而提高渲染性能。

具体来说,React 在进行更新时,会根据 key 属性来判断哪些节点需要更新,哪些节点需要删除,哪些节点需要新增。如果两个节点的 key 值相同,则 React 认为它们是同一个节点,会尝试进行复用,否则会销毁旧节点并创建新节点。如果没有 key 属性,React 无法判断哪些节点是同一个节点,就需要进行全量更新,这会导致性能下降。

另外,添加 key 属性还可以解决一些潜在的问题。例如,当我们通过 map 函数生成节点列表时,如果没有给节点添加 key 属性,当列表中的某个节点发生变化时,可能会导致其他节点的 props 或状态也被重置。如果给每个节点添加了唯一的 key 属性,就可以避免这种问题。

因此,总的来说,为节点列表添加 key 属性可以提高渲染性能,并避免潜在的问题。

131.react lazy import 实现懒加载的原理是什么?【web框架】

React 的 lazy 函数可以实现代码分割,即将代码按需加载,以达到优化页面加载速度的目的。它的原理是基于 JavaScript 的动态 import() 方法实现的。

当我们使用 lazy 函数加载一个组件时,React 会自动将该组件的代码单独打包成一个单独的 JavaScript 文件,并在需要时通过网络请求加载该文件。具体来说,lazy 函数返回的是一个特殊的组件,该组件在加载时会调用传入的函数并异步加载组件代码。一般来说,我们会将异步加载的组件通过 import() 方法引入,例如:

jsCopy codeconst MyComponent = React.lazy(() => import('./MyComponent'));

这里的 import() 方法会返回一个 Promise,该 Promise 在组件代码加载完成后会 resolve,然后通过 React 渲染该组件。

需要注意的是,由于异步加载组件的代码是在运行时执行的,而不是在构建时,因此需要使用符合 ECMAScript 标准的动态 import() 方法。此外,在使用 lazy 函数时还需要将组件包裹在 Suspense 组件中,以处理组件加载时的占位符或错误状态。例如:

jsCopy codeimport React, { lazy, Suspense } from 'react';

const MyComponent = lazy(() => import('./MyComponent'));

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <MyComponent />
      </Suspense>
    </div>
  );
}

这里的 fallback 属性指定了组件加载时的占位符,当组件加载完成后会自动替换成真正的组件。

综上所述,React 的 lazy 函数通过使用动态 import() 方法实现了组件代码的按需加载,以达到优化页面加载速度的目的。

136.如何分析页面加载慢?【工程化】

137.【性能】以用户为中心的前端性能指标有哪些?【工程化】

138.浏览器渲染进程了解多少?【浏览器】

浏览器进程:

浏览器是一个多进程的架构,当我们每开一个tab页面,就会开一个新的进程,所以如果一个页面崩溃也不会影响到别的页面。面试的时候经常会问从输入url到页面显示都发生了什么,这次主要说说针对渲染这块而浏览器具体都做了些什么,都有哪些进程?

首先浏览器进程有如下几部分:主进程第三方插件进程,GPU进程,渲染进程

而渲染进程又包含了很多线程:js引擎线程,事件触发线程,定时器触发线程,异步http请求线程,GUI渲染线程。

image.png

主进程:负责页面的显示与交互,各个页面的管理,创建和销毁其他进程。网络的资源管理和下载。

GPU进程: 最多有一个,3d绘制等。

插件进程: 每种类型的插件对应一个进程。

渲染进程:称为浏览器渲染或浏览器内核,内部是多线程的;主要负责页面渲染,脚本执行,事件处理等。

GUI渲染线程:

1. 负责渲染浏览器界面,解析html,css,构建dom树和render树,布局和绘制。
2. 当重绘和回流的时候就会执行这个线程
3. GUI渲染线程和js引擎线程互斥,当js引擎执行时,GUI线程就会被挂起(相当于冻结了),GUI更新会被保存在一个队列中等到js引擎空闲时立即执行。

js引擎线程:

1. 也称js内核,负责处理js脚本程序,例如v8引擎
2. 负责解析js脚本,运行代码
3. 等待任务队列中的任务,一个tab页只有一个js进程
4. 因为与GUI渲染线程互斥,所以js执行过长时间,就会造成页面渲染不连贯,导致页面渲染阻塞

事件触发线程:

1. 归属于浏览器而不是js引擎,用了控制事件循环
2. 当js引擎执行settimeout类似的代码块时,会将对应任务添加到事件线程
3. 当对应的事件符合触发条件时,会被放到任务队列的队尾,等待js引擎线程处理
4. 由于js单线程的关系,这些等待处理的事件都需要排队等待js引擎处理

定时器触发线程:

1. settimeout和setinterval所在的线程
2. 浏览器定时计数器不是由js引擎线程计数的,因此通过单独线程来计时触发定时,计时完毕后,添加到事件队列,等待js引擎执行。

异步http请求进程:

1. 在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求。
2. 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行

image.png

image.png

看图能大致了解渲染流程的七七八八,我按照我的理解重新梳理一下:

1. 构建DOM树。因为浏览器无法理解和直接使用html所以需要转换成dom树的形式,对html进行解析。
2. 样式计算,对css进行解析。首先把css文本转化成浏览器可以理解的结构--stylesheets,然后对stylesheets进行标准化处理,就是将一些属性值转化为渲染引擎更容易理解,标准化的计算值(例如,color单词形式转化为rgb,em单位转化为px),其次计算dom节点的样式属性。
3. 布局阶段。
    a. 首先创建布局:遍历dom中所有节点,并添加到布局树中。
    b. 布局计算:通过js和css,计算dom在页面上的位置。
    c. 最后创建布局树。
4. 分层。根据复杂的3d转换,页面滚动,还有z-index属性都会形成单独图层,把图层按照正确顺序排列。生成分层树。
5. 图层绘制,栅格化以及图层显示。对每个图层进行单独的绘制,并提交到合成器线程。
6. 合成线程将图层分为图块,并在栅格化线程池中将图块转化为位图。
7. 合成线程发送绘制图块命令drawquads给浏览器进程。
8. 浏览器根据drawquads消息生成页面展示出来

css阻塞,js阻塞:

关于提高页面性能经常听到建议说:把css代码放头部,js代码放底部。还有如果script和link都在头部,应该把script放上面。

css不会阻塞DOM解析,css阻塞DOM渲染:

image.png

从这个渲染流程图可以看出,dom解析的时候,也可以进行css的解析

js阻塞DOM解析:

如果“script”和link都在头部,把link放在头部。就会发生阻塞,浏览器会先去下载css样式,再执行js,再执行dom。 因为浏览器不知道js脚本会写些什么,如果有删除dom操作,那提前解析dom就是无用功。不过浏览器也会先“偷看”下html中是否有碰到如link、script和img等标签时,它会帮助我们先行下载里面的资源,不会傻等到解析到那里时才下载。

我们在优化js阻塞的时候经常会用defer和async异步进行js的解析,那这两个有什么区别呢?

async:

image.png

image.png

在html解析的时候,async异步的解析js,如果js解析完毕,html还没解析完,就会停止html解析,立即执行js; 如果html解析完了就正好,直接执行js。所以还是有可能阻塞html。

defer:

image.png

在html解析的时候,defer可以异步的支持解析js,等到html解析完成后,才会执行js。必然不会阻塞html。

140.pnpm 了解多少?【工程化】

pnpm,英文里面的意思叫做 performant npm ,意味“高性能的 npm”,官网地址可以参考 pnpm.io/。

pnpm 相比较于 yarn/npm 这两个常用的包管理工具在性能上也有了极大的提升,根据目前官方提供的 benchmark 数据可以看出在一些综合场景下比 npm/yarn 快了大概两倍:

在这篇文章中,将会介绍一些关于 pnpm 在依赖管理方面的优化,在 monorepo 中相比较于 yarn workspace 的应用,以及也会介绍一些 pnpm 目前存在的一些缺陷,包括讨论一下未来 pnpm 会做的一些事情。

依赖管理

这节会通过 pnpm 在依赖管理这一块的一些不同于正常包管理工具的一些优化技巧。

hard link 机制

介绍 pnpm 一定离不开的就是关于 pnpm 在安装依赖方面做的一些优化,根据前面的 benchmark 图可以看到其明显的性能提升。

那么 pnpm 是怎么做到如此大的提升的呢?是因为计算机里面一个叫做 Hard link 的机制,hard link 使得用户可以通过不同的路径引用方式去找到某个文件。pnpm 会在全局的 store 目录里存储项目 node_modules 文件的 hard links

举个例子,例如项目里面有个 1MB 的依赖 a,在 pnpm 中,看上去这个 a 依赖同时占用了 1MB 的 node\_modules 目录以及全局 store 目录 1MB 的空间(加起来是 2MB),但因为 hard link 的机制使得两个目录下相同的 1MB 空间能从两个不同位置进行寻址,因此实际上这个 a 依赖只用占用 1MB 的空间,而不是 2MB。

Store 目录

上一节提到 store 目录用于存储依赖的 hard links,这一节简单介绍一下这个 store 目录。

一般 store 目录默认是设置在 ${os.homedir}/.pnpm-store 这个目录下,具体可以参考 @pnpm/store-path 这个 pnpm 子包中的代码:

const homedir = os.homedir()
if (await canLinkToSubdir(tempFile, homedir)) {
  await fs.unlink(tempFile)
  // If the project is on the drive on which the OS home directory
  // then the store is placed in the home directory
  return path.join(homedir, relStore, STORE_VERSION)
}

当然用户也可以在 .npmrc 设置这个 store 目录位置,不过一般而言 store 目录对于用户来说感知程度是比较小的。

因为这样一个机制,导致每次安装依赖的时候,如果是个相同的依赖,有好多项目都用到这个依赖,那么这个依赖实际上最优情况(即版本相同)只用安装一次。

如果是 npm 或 yarn,那么这个依赖在多个项目中使用,在每次安装的时候都会被重新下载一次。

03

如图可以看到在使用 pnpm 对项目安装依赖的时候,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。

当然这里你可能也会有问题:如果安装了很多很多不同的依赖,那么 store 目录会不会越来越大?

答案是当然会存在,针对这个问题,pnpm 提供了一个命令来解决这个问题: pnpm store | pnpm

同时该命令提供了一个选项,使用方法为 pnpm store prune ,它提供了一种用于删除一些不被全局项目所引用到的 packages 的功能,例如有个包 axios@1.0.0 被一个项目所引用了,但是某次修改使得项目里这个包被更新到了 1.0.1 ,那么 store 里面的 1.0.0 的 axios 就就成了个不被引用的包,执行 pnpm store prune 就可以在 store 里面删掉它了。

该命令推荐偶尔进行使用,但不要频繁使用,因为可能某天这个不被引用的包又突然被哪个项目引用了,这样就可以不用再去重新下载这个包了。

node\_modules 结构

在 pnpm 官网有一篇很经典的文章,关于介绍 pnpm 项目的 node\_modules 结构: Flat node\_modules is not the only way | pnpm

在这篇文章中介绍了 pnpm 目前的 node\_modules 的一些文件结构,例如在项目中使用 pnpm 安装了一个叫做 express 的依赖,那么最后会在 node\_modules 中形成这样两个目录结构:

node_modules/express/...
node_modules/.pnpm/express@4.17.1/node_modules/xxx

其中第一个路径是 nodejs 正常寻找路径会去找的一个目录,如果去查看这个目录下的内容,会发现里面连个 node_modules 文件都没有:

▾ express
    ▸ lib
      History.md
      index.js
      LICENSE
      package.json
      Readme.md

实际上这个文件只是个软连接,它会形成一个到第二个目录的一个软连接(类似于软件的快捷方式),这样 node 在找路径的时候,最终会找到 .pnpm 这个目录下的内容。

其中这个 .pnpm 是个虚拟磁盘目录,然后 express 这个依赖的一些依赖会被平铺到 .pnpm/express@4.17.1/node_modules/ 这个目录下面,这样保证了依赖能够 require 到,同时也不会形成很深的依赖层级。

在保证了 nodejs 能找到依赖路径的基础上,同时也很大程度上保证了依赖能很好的被放在一起。

pnpm 对于不同版本的依赖有着极其严格的区分要求,如果项目中某个依赖实际上依赖的 peerDeps 出现了具体版本上的不同,对于这样的依赖会在虚拟磁盘目录 .pnpm 有一个比较严格的区分,具体可以参考: pnpm.io/how-peers-a… 这篇文章。

综合而言,本质上 pnpm 的 node_modules 结构是个网状 + 平铺的目录结构。这种依赖结构主要基于软连接(即 symlink)的方式来完成。

symlink 和 hard link 机制

在前面知道了 pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node\_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。

这两者结合在一起工作之后,假如有一个项目依赖了 bar@1.0.0foo@1.0.0 ,那么最后的 node\_modules 结构呈现出来的依赖结构可能会是这样的:

node_modules
└── bar // symlink to .pnpm/bar@1.0.0/node_modules/bar
└── foo // symlink to .pnpm/foo@1.0.0/node_modules/foo
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       └── bar -> <store>/bar
    │           ├── index.js
    │           └── package.json
    └── foo@1.0.0
        └── node_modules
            └── foo -> <store>/foo
                ├── index.js
                └── package.json

node_modules 中的 bar 和 foo 两个目录会软连接到 .pnpm 这个目录下的真实依赖中,而这些真实依赖则是通过 hard link 存储到全局的 store 目录中。

兼容问题

读到这里,可能有用户会好奇: 像 hard link 和 symlink 这种方式在所有的系统上都是兼容的吗?

实际上 hard link 在主流系统上(Unix/Win)使用都是没有问题的,但是 symlink 即软连接的方式可能会在 windows 存在一些兼容的问题,但是针对这个问题,pnpm 也提供了对应的解决方案:

在 win 系统上使用一个叫做 junctions 的特性来替代软连接,这个方案在 win 上的兼容性要好于 symlink。

或许你也会好奇为啥 pnpm 要使用 hard links 而不是全都用 symlink 来去实现。

实际上存在 store 目录里面的依赖也是可以通过软连接去找到的,nodejs 本身有提供一个叫做 --preserve-symlinks 的参数来支持 symlink,但实际上这个参数实际上对于 symlink 的支持并不好导致作者放弃了该方案从而采用 hard links 的方式:

具体可以参考 github.com/nodejs/node… 该issue 讨论。

Monorepo 支持

pnpm 在 monorepo 场景可以说算得上是个完美的解决方案了,因为其本身的设计机制,导致很多关键或者说致命的问题都得到了相当有效的解决。

workspace 支持

对于 monorepo 类型的项目,pnpm 提供了 workspace 来支持,具体可以参考官网文档: pnpm.io/workspaces/…

痛点解决

Monorepo 下被人诟病较多的问题,一般是依赖结构问题。常见的两个问题就是 Phantom dependenciesNPM doppelgangers,用 rush 官网 的图片可以很贴切的展示着两个问题:

下面会针对两个问题一一介绍。

Phantom dependencies

Phantom dependencies 被称之为幽灵依赖,解释起来很简单,即某个包没有被安装(package.json 中并没有,但是用户却能够引用到这个包)。

引发这个现象的原因一般是因为 node\_modules 结构所导致的,例如使用 yarn 对项目安装依赖,依赖里面有个依赖叫做 foo,foo 这个依赖同时依赖了 bar,yarn 会对安装的 node\_modules 做一个扁平化结构的处理(npm v3 之后也是这么做的),会把依赖在 node\_modules 下打平,这样相当于 foo 和 bar 出现在同一层级下面。那么根据 nodejs 的寻径原理,用户能 require 到 foo,同样也能 require 到 bar。

package.json -> foo(bar 为 foo 依赖)

node_modules

  /foo

  /bar -> 👻依赖

那么这里这个 bar 就成了一个幽灵依赖,如果某天某个版本的 foo 依赖不再依赖 bar 或者 foo 的版本发生了变化,那么 require bar 的模块部分就会抛错。

以上其实只是一个简单的例子,但是根据笔者在字节内部见到的一些 monorepo(主要为 lerna + yarn )项目中,这其实是个比较常见的现象,甚至有些包会直接去利用这种残缺的引入方式去减轻包体积。

还有一种场景就是在 lerna + yarn workspace 的项目里面,因为 yarn 中提供了 hoist 机制(即一些底层子项目的依赖会被提升到顶层的 node_modules 中),这种 phantom dependencies 会更多,一些底层的子项目经常会去 require 一些在自己里面没有引入的依赖,而直接去找顶层 node\_modules 的依赖(nodejs 这里的寻径是个递归上下的过程)并使用。

而根据前面提到的 pnpm 的 node_modules 依赖结构,这种现象是显然不会发生的,因为被打平的依赖会被放到 .pnpm 这个虚拟磁盘目录下面去,用户通过 require 是根本找不到的。

值得一提的是,pnpm 本身其实也提供了将依赖提升并且按照 yarn 那种形式组织的 node\_modules 结构的 Option,作者将其命名为 --shamefully-hoist ,即 "羞耻的 hoist".....
NPM doppelgangers

这个问题其实也可以说是 hoist 导致的,这个问题可能会导致有大量的依赖的被重复安装,举个例子:

例如有个 package,下面依赖有 lib\_a、lib\_b、lib\_c、lib\_d,其中 a 和 b 依赖 util\_e@1.0.0,而 c 和 d 依赖 util\_e@2.0.0

那么早期 npm 的依赖结构应该是这样的:

- package
- package.json
- node_modules
- lib_a
  - node_modules <- util_e@1.0.0
- lib_b
  - node_modules <- util_e@1.0.0
_ lib_c
  - node_modules <- util_e@2.0.0
- lib_d
  - node_modules <- util_e@2.0.0

这样必然会导致很多依赖被重复安装,于是就有了 hoist 和打平依赖的操作:

- package
- package.json
- node_modules
- util_e@1.0.0
- lib_a
- lib_b
_ lib_c
  - node_modules <- util_e@2.0.0
- lib_d
  - node_modules <- util_e@2.0.0

但是这样也只能提升一个依赖,如果两个依赖都提升了会导致冲突,这样同样会导致一些不同版本的依赖被重复安装多次,这里就会导致使用 npm 和 yarn 的性能损失。

如果是 pnpm 的话,这里因为依赖始终都是存在 store 目录下的 hard links ,一份不同的依赖始终都只会被安装一次,因此这个是能够被彻彻底底的消除的。

目前不适用的场景

前面有提到关于 pnpm 的主要问题在于 symlink(软链接)在一些场景下会存在兼容的问题,可以参考作者在 nodejs 那边开的一个 discussion:github.com/nodejs/node…

在里面作者提到了目前 nodejs 软连接不能适用的一些场景,希望 nodejs 能提供一种 link 方式而不是使用软连接,同时也提到了 pnpm 目前因为软连接而不能使用的场景:

  • Electron 应用无法使用 pnpm
  • 部署在 lambda 上的应用无法使用 pnpm

笔者在字节内部使用 pnpm 时也遇到过一些 nodejs 基础库不支持 symlink 的情况导致使用 pnpm 无法正常工作,不过这些库在迭代更新之后也会支持这一特性。

141.如何组织 monorepo 工程?【工程化】

参考文档:

144.[vue] 是怎么解析template的?【web框架】

整体流程图:
image

参考文档:

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

116.React Diff算法是怎么实现的?【JavaScript】

原理

React 中的 Diff 算法,是用于比较新旧两个虚拟 DOM 树,找出需要更新的节点并进行更新的算法。React 的 Diff 算法实现基于以下假设:

  1. 两个不同类型的元素会产生不同的树形结构。
  2. 对于同一层级的一组子节点,它们可以通过唯一 id 匹配到相同的节点。
  3. 每个组件都有一个唯一标识符 key。

基于以上假设,React 的 Diff 算法分为两个阶段:

  1. O(n) 的遍历,对比新旧两棵树的每一个节点,并记录节点的变更。在这个过程中,React 使用了双端队列(Double-ended queue)作为辅助数据结构,以保证遍历的高效性。
  2. O(k) 的反向遍历,根据记录的变更列表对 DOM 进行更新。

在第一阶段中,React 的 Diff 算法会从两棵树的根节点开始,依次对比它们的子节点。如果某个节点在新旧两个树中都存在,那么就将其进行更新。如果新树中有新节点,那么就将其插入到旧树中对应的位置。如果旧树中有节点不存在于新树中,那么就将其从 DOM 树中移除。

在第二阶段中,React 会根据记录的变更列表对 DOM 进行更新。这个过程中,React 会按照更新的优先级进行更新,优先更新需要移动的节点,其次更新需要删除的节点,最后再更新需要插入的节点。

需要注意的是,React 的 Diff 算法并不保证一定找到最优解,但是它保证了在大多数情况下,找到的解都是比较优的。同时,React 的 Diff 算法也具有一定的限制,比如无法跨越组件边界进行优化,这也是 React 中尽量避免多层嵌套组件的原因之一。

代码模拟实现

React diff算法是一种优化算法,用于比较两个虚拟DOM树的差异,以最小化DOM操作的数量,从而提高渲染性能。
以下是一个简单的实现React diff算法的代码:

function diff(oldTree, newTree) {
  const patches = {};
  let index = 0;
  walk(oldTree, newTree, index, patches);
  return patches;
}

function walk(oldNode, newNode, index, patches) {
  const currentPatch = [];

  if (!newNode) {
    currentPatch.push({ type: "REMOVE" });
  } else if (typeof oldNode === "string" && typeof newNode === "string") {
    if (oldNode !== newNode) {
      currentPatch.push({ type: "TEXT", content: newNode });
    }
  } else if (oldNode.type === newNode.type) {
    const attrs = diffAttrs(oldNode.props, newNode.props);
    if (Object.keys(attrs).length > 0) {
      currentPatch.push({ type: "ATTRS", attrs });
    }
    diffChildren(oldNode.children, newNode.children, index, patches, currentPatch);
  } else {
    currentPatch.push({ type: "REPLACE", newNode });
  }

  if (currentPatch.length > 0) {
    patches[index] = currentPatch;
  }
}

function diffAttrs(oldAttrs, newAttrs) {
  const attrs = {};
  for (const key in oldAttrs) {
    if (oldAttrs[key] !== newAttrs[key]) {
      attrs[key] = newAttrs[key];
    }
  }
  for (const key in newAttrs) {
    if (!oldAttrs.hasOwnProperty(key)) {
      attrs[key] = newAttrs[key];
    }
  }
  return attrs;
}

function diffChildren(oldChildren, newChildren, index, patches, currentPatch) {
  const diffs = listDiff(oldChildren, newChildren, "key");
  newChildren = diffs.children;

  if (diffs.moves.length > 0) {
    const reorderPatch = { type: "REORDER", moves: diffs.moves };
    currentPatch.push(reorderPatch);
  }

  let lastIndex = index;
  oldChildren.forEach((child, i) => {
    const newChild = newChildren[i];
    index = lastIndex + 1;
    walk(child, newChild, index, patches);
    lastIndex = index;
  });
}

function listDiff(oldList, newList, key) {
  const oldMap = makeKeyIndexAndFree(oldList, key);
  const newMap = makeKeyIndexAndFree(newList, key);

  const newFree = newMap.free;

  const moves = [];

  const children = [];
  let i = 0;
  let item;
  let itemIndex;
  let freeIndex = 0;

  while (i < oldList.length) {
    item = oldList[i];
    itemIndex = oldMap.keyIndex[item[key]];
    if (itemIndex === undefined) {
      moves.push({ index: i, type: "REMOVE" });
    } else {
      children.push(newList[itemIndex]);
      if (itemIndex >= freeIndex) {
        freeIndex = itemIndex + 1;
      } else {
        moves.push({ index: itemIndex, type: "INSERT", item: item });
      }
    }
    i++;
  }

  const remaining = newFree.slice(freeIndex);
  remaining.forEach(item => {
    moves.push({ index: newList.indexOf(item), type: "INSERT", item: item });
  });

  return { moves, children };
}

function makeKeyIndexAndFree(list, key) {
  const keyIndex = {};
  const free = [];
  for (let i = 0; i < list.length; i++) {
    const item = list[i];
    if (item[key] !== undefined) {
      keyIndex[item[key]] = i;
    } else {
      free.push(item);
    }
  }
  return { keyIndex, free };
}

145.实现 JS 沙盒的方式有哪些?【工程化】

微前端已经成为前端领域比较火爆的话题,在技术方面,微前端有一个始终绕不过去的话题就是前端沙箱

什么是沙箱

Sandboxie(又叫沙箱、沙盘)即是一个虚拟系统程序,允许你在沙盘环境中运行浏览器或其他程序,因此运行所产生的变化可以随后删除。它创造了一个类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响。 在网络安全中,沙箱指在隔离环境中,用以测试不受信任的文件或应用程序等行为的工具

简单来说沙箱(sandbox)就是与外界隔绝的一个环境,内外环境互不影响,外界无法修改该环境内任何信息,沙箱内的东西单独属于一个世界。

JavaScript 的沙箱

对于 JavaScript 来说,沙箱并非传统意义上的沙箱,它只是一种语法上的 Hack 写法,沙箱是一种安全机制,把一些不信任的代码运行在沙箱之内,使其不能访问沙箱之外的代码。当需要解析或着执行不可信的 JavaScript 的时候,需要隔离被执行代码的执行环境的时候,需要对执行代码中可访问对象进行限制,通常开始可以把 JavaScript 中处理模块依赖关系的闭包称之为沙箱。

JavaScript 沙箱实现

我们大致可以把沙箱的实现总体分为两个部分:

  • 构建一个闭包环境
  • 模拟原生浏览器对象

构建闭包环境

我们知道 JavaScript 中,关于作用域(scope),只有全局作用域(global scope)、函数作用域(function scope)以及从 ES6 开始才有的块级作用域(block scope)。如果要将一段代码中的变量、函数等的定义隔离出来,受限于 JavaScript 对作用域的控制,只能将这段代码封装到一个 Function 中,通过使用 function scope 来达到作用域隔离的目的。也因为需要这种使用函数来达到作用域隔离的目的方式,于是就有 IIFE(立即调用函数表达式),这是一个被称为 自执行匿名函数的设计模式

 (function foo(){
    var a = 1;
    console.log(a);
 })();
 // 无法从外部访问变量 name
console.log(a) // 抛出错误:"Uncaught ReferenceError: a is not defined"

当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问,它拥有独立的词法作用域。不仅避免了外界访问 IIFE 中的变量,而且又不会污染全局作用域,弥补了 JavaScript 在 scope 方面的缺陷。一般常见于写插件和类库时,如 JQuery 当中的沙箱模式

(function (window) {
    var jQuery = function (selector, context) {
        return new jQuery.fn.init(selector, context);
    }
    jQuery.fn = jQuery.prototype = function () {
        //原型上的方法,即所有jQuery对象都可以共享的方法和属性
    }
    jQuery.fn.init.prototype = jQuery.fn;
    window.jQeury = window.$ = jQuery; //如果需要在外界暴露一些属性或者方法,可以将这些属性和方法加到window全局对象上去
})(window);

当将 IIFE 分配给一个变量,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。

var result = (function () {
    var name = "张三";
    return name;
})();
console.log(result); // "张三"

原生浏览器对象的模拟

模拟原生浏览器对象的目的是为了,防止闭包环境,操作原生对象。篡改污染原生环境;完成模拟浏览器对象之前我们需要先关注几个不常用的 API。

eval

eval 函数可将字符串转换为代码执行,并返回一个或多个值

   var b = eval("({name:'张三'})")
   console.log(b.name);

由于 eval 执行的代码可以访问闭包和全局范围,因此就导致了代码注入的安全问题,因为代码内部可以沿着作用域链往上找,篡改全局变量,这是我们不希望的

new Function

Function 构造函数创建一个新的 Function 对象。直接调用这个构造函数可用动态创建函数

语法

new Function ([arg1[, arg2[, ...argN]],] functionBody)

arg1, arg2, ... argN 被函数使用的参数的名称必须是合法命名的。参数名称是一个有效的 JavaScript 标识符的字符串,或者一个用逗号分隔的有效字符串的列表;例如“×”,“theValue”,或“a,b”。

functionBody 一个含有包括函数定义的 JavaScript 语句的字符串。

const sum = new Function('a', 'b', 'return a + b');

console.log(sum(1, 2));//3

同样也会遇到和 eval 类似的的安全问题和相对较小的性能问题。

var a = 1;

function sandbox() {
    var a = 2;
    return new Function('return a;'); // 这里的 a 指向最上面全局作用域内的 1
}
var f = sandbox();
console.log(f())

与 eval 不同的是 Function 创建的函数只能在全局作用域中运行。它无法访问局部闭包变量,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function 构造器创建时所在的作用域的变量;但是,它仍然可以访问全局范围。new Function()是 eval()更好替代方案。它具有卓越的性能和安全性,但仍没有解决访问全局的问题。

with

with 是 JavaScript 中一个关键字,扩展一个语句的作用域链。它允许半沙盒执行。那什么叫半沙盒?语句将某个对象添加到作用域链的顶部,如果在沙盒中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出 ReferenceError。

function sandbox(o) {
    with (o){
        //a=5; 
        c=2;
        d=3;
        console.log(a,b,c,d); // 0,1,2,3 //每个变量首先被认为是一个局部变量,如果局部变量与 obj 对象的某个属性同名,则这个局部变量会指向 obj 对象属性。
    }

}
var f = {
    a:0,
    b:1
}
sandbox(f);
console.log(f);
console.log(c,d); // 2,3 c、d被泄露到window对象上

究其原理,with在内部使用in运算符。对于块内的每个变量访问,它都在沙盒条件下计算变量。如果条件是 true,它将从沙盒中检索变量。否则,就在全局范围内查找变量。但是 with 语句使程序在查找变量值时,都是先在指定的对象中查找。所以对于那些本来不是这个对象的属性的变量,查找起来会很慢,对于有性能要求的程序不适合(JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。)。with 也会导致数据泄漏(在非严格模式下,会自动在全局作用域创建一个全局变量)

in 运算符

in 运算符能够检测左侧操作数是否为右侧操作数的成员。其中,左侧操作数是一个字符串,或者可以转换为字符串的表达式,右侧操作数是一个对象或数组。
var o = {
    a : 1,
    b : function() {}
}
console.log("a" in o);  //true
console.log("b" in o);  //true
console.log("c" in o);  //false
console.log("valueOf" in o);  //返回true,继承Object的原型方法
console.log("constructor" in o);  //返回true,继承Object的原型属性

with + new Function

配合 with 用法可以稍微限制沙盒作用域,先从当前的 with 提供对象查找,但是如果查找不到依然还能从上获取,污染或篡改全局环境。

function sandbox (src) {
    src = 'with (sandbox) {' + src + '}'
    return new Function('sandbox', src)
}
var str = 'let a = 1;window.name="张三";console.log(a);console.log(b)'
var b = 2
sandbox(str)({});
console.log(window.name);//'张三'

基于 Proxy 实现的沙箱(ProxySandbox)

由上部分内容思考,假如可以做到在使用with对于块内的每个变量访问都限制在沙盒条件下计算变量,从沙盒中检索变量。那么是否可以完美的解决JavaScript沙箱机制。

使用 with 再加上 proxy 实现 JavaScript 沙箱

ES6 Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,属于一种“元编程”(meta programming)
function sandbox(code) {
    code = 'with (sandbox) {' + code + '}'
    const fn = new Function('sandbox', code)

    return function (sandbox) {
        const sandboxProxy = new Proxy(sandbox, {
            has(target, key) {
                return true
            }
        })
        return fn(sandboxProxy)
    }
}
var a = 1;
var code = 'console.log(a)' // TypeError: Cannot read property 'log' of undefined
sandbox(code)({})

我们前面提到with在内部使用in运算符来计算变量,如果条件是 true,它将从沙盒中检索变量。理想状态下没有问题,但也总有些特例独行的存在,比如 Symbol.unscopables。

Symbol.unscopables

Symbol.unscopables 对象的 Symbol.unscopables 属性,指向一个对象。该对象指定了使用 with 关键字时,哪些属性会被 with 环境排除。
Array.prototype[Symbol.unscopables]
// {
//   copyWithin: true,
//   entries: true,
//   fill: true,
//   find: true,
//   findIndex: true,
//   keys: true
// }

Object.keys(Array.prototype[Symbol.unscopables])
// ['copyWithin', 'entries', 'fill', 'find', 'findIndex', 'keys']

由此我们的代码还需要修改如下:

function sandbox(code) {
    code = 'with (sandbox) {' + code + '}'
    const fn = new Function('sandbox', code)

    return function (sandbox) {
        const sandboxProxy = new Proxy(sandbox, {
            has(target, key) {
                return true
            },
            get(target, key) {
                if (key === Symbol.unscopables) return undefined
                return target[key]
            }
        })
        return fn(sandboxProxy)
    }
}
var test = {
    a: 1,
    log(){
        console.log('11111')
    }
}
var code = 'log();console.log(a)' // 1111,TypeError: Cannot read property 'log' of undefined
sandbox(code)(test)

Symbol.unscopables 定义对象的不可作用属性。Unscopeable 属性永远不会从 with 语句中的沙箱对象中检索,而是直接从闭包或全局范围中检索。

快照沙箱(SnapshotSandbox)

以下是 qiankun 的 snapshotSandbox 的源码,这里为了帮助理解做部分精简及注释。

function iter(obj, callbackFn) {
    for (const prop in obj) {
        if (obj.hasOwnProperty(prop)) {
            callbackFn(prop);
        }
    }
}

/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
class SnapshotSandbox {
    constructor(name) {
        this.name = name;
        this.proxy = window;
        this.type = 'Snapshot';
        this.sandboxRunning = true;
        this.windowSnapshot = {};
        this.modifyPropsMap = {};
        this.active();
    }
    //激活
    active() {
        // 记录当前快照
        this.windowSnapshot = {};
        iter(window, (prop) => {
            this.windowSnapshot[prop] = window[prop];
        });

        // 恢复之前的变更
        Object.keys(this.modifyPropsMap).forEach((p) => {
            window[p] = this.modifyPropsMap[p];
        });

        this.sandboxRunning = true;
    }
    //还原
    inactive() {
        this.modifyPropsMap = {};

        iter(window, (prop) => {
            if (window[prop] !== this.windowSnapshot[prop]) {
                // 记录变更,恢复环境
                this.modifyPropsMap[prop] = window[prop];

                window[prop] = this.windowSnapshot[prop];
            }
        });
        this.sandboxRunning = false;
    }
}
let sandbox = new SnapshotSandbox();
//test
((window) => {
    window.name = '张三'
    window.age = 18
    console.log(window.name, window.age) //    张三,18
    sandbox.inactive() //    还原
    console.log(window.name, window.age) //    undefined,undefined
    sandbox.active() //    激活
    console.log(window.name, window.age) //    张三,18
})(sandbox.proxy);

快照沙箱实现来说比较简单,主要用于不支持 Proxy 的低版本浏览器,原理是基于diff来实现的,在子应用激活或者卸载时分别去通过快照的形式记录或还原状态来实现沙箱,snapshotSandbox 会污染全局 window。

legacySandBox

qiankun 框架 singular 模式下 proxy 沙箱实现,为了便于理解,这里做了部分代码的精简和注释。

//legacySandBox
const callableFnCacheMap = new WeakMap();

function isCallable(fn) {
  if (callableFnCacheMap.has(fn)) {
    return true;
  }
  const naughtySafari = typeof document.all === 'function' && typeof document.all === 'undefined';
  const callable = naughtySafari ? typeof fn === 'function' && typeof fn !== 'undefined' : typeof fn ===
    'function';
  if (callable) {
    callableFnCacheMap.set(fn, callable);
  }
  return callable;
};

function isPropConfigurable(target, prop) {
  const descriptor = Object.getOwnPropertyDescriptor(target, prop);
  return descriptor ? descriptor.configurable : true;
}

function setWindowProp(prop, value, toDelete) {
  if (value === undefined && toDelete) {
    delete window[prop];
  } else if (isPropConfigurable(window, prop) && typeof prop !== 'symbol') {
    Object.defineProperty(window, prop, {
      writable: true,
      configurable: true
    });
    window[prop] = value;
  }
}


function getTargetValue(target, value) {
  /*
    仅绑定 isCallable && !isBoundedFunction && !isConstructable 的函数对象,如 window.console、window.atob 这类。目前没有完美的检测方式,这里通过 prototype 中是否还有可枚举的拓展方法的方式来判断
    @warning 这里不要随意替换成别的判断方式,因为可能触发一些 edge case(比如在 lodash.isFunction 在 iframe 上下文中可能由于调用了 top window 对象触发的安全异常)
   */
  if (isCallable(value) && !isBoundedFunction(value) && !isConstructable(value)) {
    const boundValue = Function.prototype.bind.call(value, target);
    for (const key in value) {
      boundValue[key] = value[key];
    }
    if (value.hasOwnProperty('prototype') && !boundValue.hasOwnProperty('prototype')) {
      Object.defineProperty(boundValue, 'prototype', {
        value: value.prototype,
        enumerable: false,
        writable: true
      });
    }

    return boundValue;
  }

  return value;
}

/**
 * 基于 Proxy 实现的沙箱
 */
class SingularProxySandbox {
  /** 沙箱期间新增的全局变量 */
  addedPropsMapInSandbox = new Map();

  /** 沙箱期间更新的全局变量 */
  modifiedPropsOriginalValueMapInSandbox = new Map();

  /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
  currentUpdatedPropsValueMap = new Map();

  name;

  proxy;

  type = 'LegacyProxy';

  sandboxRunning = true;

  latestSetProp = null;

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  inactive() {
    // console.log(' this.modifiedPropsOriginalValueMapInSandbox', this.modifiedPropsOriginalValueMapInSandbox)
    // console.log(' this.addedPropsMapInSandbox', this.addedPropsMapInSandbox)
    //删除添加的属性,修改已有的属性
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

  constructor(name) {
    this.name = name;
    const {
      addedPropsMapInSandbox,
      modifiedPropsOriginalValueMapInSandbox,
      currentUpdatedPropsValueMap
    } = this;

    const rawWindow = window;
    //Object.create(null)的方式,传入一个不含有原型链的对象
    const fakeWindow = Object.create(null);

    const proxy = new Proxy(fakeWindow, {
      set: (_, p, value) => {
        if (this.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
            const originalValue = rawWindow[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }

          currentUpdatedPropsValueMap.set(p, value);
          // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
          rawWindow[p] = value;

          this.latestSetProp = p;

          return true;
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      get(_, p) {
        //避免使用 window.window 或者 window.self 逃离沙箱环境,触发到真实环境
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }
        const value = rawWindow[p];
        return getTargetValue(rawWindow, value);
      },

      has(_, p) { //返回boolean
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_, p) {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        // 如果属性不作为目标对象的自身属性存在,则不能将其设置为不可配置
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },
    });

    this.proxy = proxy;
  }
}

let sandbox = new SingularProxySandbox();

((window) => {
  window.name = '张三';
  window.age = 18;
  window.sex = '男';
  console.log(window.name, window.age,window.sex) //    张三,18,男
  sandbox.inactive() //    还原
  console.log(window.name, window.age,window.sex) //    张三,undefined,undefined
  sandbox.active() //    激活
  console.log(window.name, window.age,window.sex) //    张三,18,男
})(sandbox.proxy); //test

legacySandBox 还是会操作 window 对象,但是他通过激活沙箱时还原子应用的状态,卸载时还原主应用的状态来实现沙箱隔离的,同样会对 window 造成污染,但是性能比快照沙箱好,不用遍历 window 对象。

proxySandbox(多例沙箱)

在 qiankun 的沙箱 proxySandbox 源码里面是对 fakeWindow 这个对象进行了代理,而这个对象是通过 createFakeWindow 方法得到的,这个方法是将 window 的 document、location、top、window 等等属性拷贝一份,给到 fakeWindow。

源码展示:


function createFakeWindow(global: Window) {
  // map always has the fastest performance in has check scenario
  // see https://jsperf.com/array-indexof-vs-set-has/23
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  /*
   copy the non-configurable property of global to fakeWindow
   see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
   > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
   */
  Object.getOwnPropertyNames(global)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        /*
         make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
         > The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
         */
        if (
          p === 'top' ||
          p === 'parent' ||
          p === 'self' ||
          p === 'window' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          descriptor.configurable = true;
          /*
           The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
           Example:
            Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
            Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
           */
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }

        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

proxySandbox 由于是拷贝复制了一份 fakeWindow,不会污染全局 window,同时支持多个子应用同时加载。 详细源码请查看:proxySandbox

关于 CSS 隔离

常见的有:

  • CSS Module
  • namespace
  • Dynamic StyleSheet
  • css in js
  • Shadow DOM 常见的我们这边不再赘述,这里我们重点提一下Shadow DO。

Shadow DOM

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。

本文含有隐藏内容,请 开通VIP 后查看