【靠它上岸】面试题-性能优化、打包...

715 阅读8分钟

monorepo

带你了解更全面的 Monorepo - 优劣、踩坑、选型 - 掘金

Monorepo 是一种项目代码管理方式,指单个仓库中管理多个项目,有助于简化代码共享、版本控制、构建和部署等方面的复杂性,并提供更好的可重用性和协作性。

代码管理方式演进

  1. 阶段一:单仓库巨石应用,一个 Git 仓库维护着项目代码,随着迭代业务复杂度的提升,项目代码会变得越来越多,越来越复杂,大量代码构建效率也会降低,最终导致了单体巨石应用,这种代码管理方式称之为 Monolith
  2. 阶段二:多仓库多模块应用,将项目拆解成多个业务模块,并在多个 Git 仓库管理,模块解耦,降低了巨石应用的复杂度,每个模块都可以独立编码、测试、发版,代码管理变得简化,构建效率也得以提升,这种代码管理方式称之为 MultiRepo。
  3. 阶段三:单仓库多模块应用,随着业务复杂度的提升,模块仓库越来越多,MultiRepo这种方式虽然从业务上解耦了,但增加了项目工程管理的难度,随着模块仓库达到一定数量级,会有几个问题:跨仓库代码难共享;分散在单仓库的模块依赖管理复杂(底层模块升级后,其他上层依赖需要及时更新,否则有问题);增加了构建耗时。于是将多个项目集成到一个仓库下,共享工程配置,同时又快捷地共享模块代码,成为趋势,这种代码管理方式称之为 MonoRepo。

|—— package.json 
|__ packages/
    |—— project1/
    |    |—— index.js
    |    |—— package.json
    |    |__ node_modules
    |—— project2/
    |    |—— index.js
    |    |—— package.json
    |    |__ node_modules

优缺点

幽灵依赖

问题:npm/yarn 安装依赖时,存在依赖提升,某个项目使用的依赖,并没有在其 package.json 中声明,也可以直接使用,这种现象称之为 “幽灵依赖”;随着项目迭代,这个依赖不再被其他项目使用,不再被安装,使用幽灵依赖的项目,会因为无法找到依赖而报错。

例如当我们项目中需要依赖 axios 时的时候,会将 axios 依赖的依赖包一起安装到当前项目中的 node_modules 下,这就形成了幽灵依赖

方案:基于 npm/yarn 的 Monorepo 方案,依然存在 “幽灵依赖” 问题,我们可以通过 pnpm 彻底解决这个问题

依赖安装耗时长

问题:MonoRepo 中每个项目都有自己的 package.json 依赖列表,随着 MonoRepo 中依赖总数的增长,每次 install 时,耗时会较长。

方案:相同版本依赖提升到 Monorepo 根目录下,减少冗余依赖安装;使用 pnpm 按需安装及依赖缓存。

构建打包耗时长

问题:多个项目构建任务存在依赖时,往往是串行构建 或 全量构建,导致构建时间较长

方案:增量构建,而非全量构建;也可以将串行构建,优化成并行构建。

webpack

Webpack HMR 原理解析

「吐血整理」再来一打Webpack面试题 - 掘金

🔥【万字】透过分析 webpack 面试题,构建 webpack5.x 知识体系 - 掘金

webpack介绍

webpack默认支持处理js与json文件,其他类型都处理不了,需要使用loader来对不同类型的文件进行处理

  • 模板打包:可以将不同模块的文件打包整合在一起,并且保证他们之间的引用正确,执行有序
  • 编译兼容:通过webpack的Loader机制,不仅仅可以帮助我们对代码做polyfill,还可以编译转换诸如.less, .vue, .jsx这类在浏览器无法识别的格式文件,让我们在开发的时候可以使用新特性和新语法做开发,提高开发效率
  • 能力扩展:通过webpack的Plugin机制,我们在实现模块化打包和编译兼容的基础上,可以进一步实现诸如按需加载,代码压缩等一系列功能,帮助我们进一步提高自动化程度,工程效率以及打包输出的质量。

webpack的主体框架:

sourcemap

当 webpack 打包源代码时,可能会很难追踪到错误和警告在源代码中的原始位置。例如,如果将三个源文件(a.js, b.jsc.js)打包到一个 bundle(bundle.js)中,而其中一个源文件包含一个错误,那么堆栈跟踪就会简单地指向到 bundle.js。这并通常没有太多帮助,因为你可能需要准确地知道错误来自于哪个源文件。

为了更容易地追踪错误和警告,JavaScript 提供了 source map 功能 ,将编译后的代码映射回原始源代码。如果一个错误来自于 b.js,source map 就会明确的告诉你。

webpack用于帮助开发人员在代码发生变化之后自动编译代码的选项

  • webpack's Watch Mode
    使用观察模式。你可以指示 webpack "watch" 依赖图中的所有文件以进行更改。如果其中一个文件被更新,代码将被重新编译,所以你不必手动运行整个构建。
  • webpack-dev-server
    为开发者提供了一个简单的 web 服务器,并且能够实时重新加载(live reloading)。
  • webpack-dev-middleware
    是一个容器(wrapper),它可以把 webpack 处理后的文件传递给一个服务器(server)。webpack-dev-server 在内部使用了它,同时,它也可以作为一个单独的包来使用,以便进行更多自定义设置来实现更多的需求。

webpack配置项

  • entry:入口
  • output:出口
  • loader:加载器,loader让webpack能够去处理其他类型的文件,并将他们转换为有效模块。

两个属性:

    • test:识别出哪些文件会被转换
    • use:定义出在进行转换时,应该使用哪个loader
  • plugins:定义项目要用的插件 扩展功能。比如打包优化,资源管理,注入环境变量
  • mode:模式
    • development:开发环境,打包速度更快,省了代码优化步骤。需要sourcemap方便定位问题
    • production:生产环境,打包比较慢,会开启tree-shaking和压缩代码
    • none:不适用任何默认优化选项

区分环境

本地环境:

  • 需要更快的构建速度
  • 需要打印debug信息
  • 需要live reload或hot reload功能
  • 需要sourcemap方便定位问题

生产环境:

  • 需要更小的包体积,代码压缩+tree-shaking
  • 需要进行代码分割
  • 需要压缩图片体积

我们可以安装croess-env进行环境的区分

  1. 安装
npm install cross-env -D
  1. 配置启动命令
"scripts": {
  "dev": "cross-env NODE_ENV=dev webpack serve --mode development", 
  "test": "cross-env NODE_ENV=test webpack --mode production",
  "build": "cross-env NODE_ENV=prod webpack --mode production"
},
  1. 在webpack配置文件中获取环境变量
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
console.log('process.env.NODE_ENV=', process.env.NODE_ENV) // 打印环境变量
const config = {
  entry: './src/index.js', // 打包入口地址
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.join(__dirname, 'dist') // 输出文件目录
  },
  module: { 
    rules: [
      {
        test: /.css$/, //匹配所有的 css 文件
        use: 'css-loader' // use: 对应的 Loader 名称
      }
    ]
  },
  plugins:[ // 配置插件
    new HtmlWebpackPlugin({
      template: './src/index.html'
    })
  ]
}
module.exports = (env, argv) => {
  console.log('argv.mode=',argv.mode) // 打印 mode(模式) 值
  // 这里可以通过不同的模式修改 config 配置
  return config;
}

webpack中loader和plugin的区别

loader运行在打包文件之前,plugins在整个编译周期都起作用

【Loader】:用于对模块源码的转换, loader描述了webpack如何处理非javascript模块,并且在build中引入这些依赖。loader可以将文件从不同的语言(如TypeScript)转换为JavaScript,或者将内联图像转换为data URL。比如说:CSS-Loader,Style-Loader等。loader的执行顺序是固定从后往前的

缩小范围:在配置loader的时候,我们需要更精确的去指定 loader 的作用目录或者需要排除的目录,通过使用 include 和 exclude 两个配置项,可以实现这个功能:

  • include:符合条件的模块进行解析
  • exclude:排除符合条件的模块,不解析
  • exclude优先级更高

常见的一些loader:

  • raw-loader:加载文件原始内容
  • file-loader:把文件输出到一个文件夹中,在代码中通过相对url去引用输出的文件
  • url-loader:与 file-loader 类似,区别是用户可以设置一个阈值,大于阈值会交给 file-loader 处理,小于阈值时返回文件 base64 形式编码 (处理图片和字体)
    file-loader和url-loader一般配合使用
  • image-loader:加载并压缩图片文件
  • json-loader:加载json文件
  • babel-loader:将es6转成es5
  • css-loader:加载css,支持模块化,压缩,文件导入特性
  • style-loader:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS (将处理好的 css 通过 style 标签的形式添加到页面上)

style-loader核心逻辑相当于

const content = ${样式内容}

const style = document.createElement('style');

style.innerHTML = content;

document.head.appendChild(style);

  • eslint-loader:通过 ESLint 检查 JavaScript 代码
  • vue-loader:加载vue.js单文件组件
  • cache-loader:在一些性能开销较大的loader之前添加该loader,将结果缓存到磁盘里
  • thread-loader:不处理具体的转换模块到js的工作,而是把他后面的loader扔进一个工作线程池
  • ts-loader:将ts转换为js
  • html-loader将解析url,并请求图片和你所期望的一切资源
  • postcss-loader:自动添加css3部分属性的浏览器前缀
    • 安装:npm install postcss postcss-loader postcss-preset-env -D
    • 添加postcss-loader加载器
const config = {
  // ...
  module: { 
    rules: [
      {
        test: /.css$/, //匹配所有的 css 文件
        use: [
          'style-loader',
          'css-loader', 
          'postcss-loader'
        ]
      }
    ]
  }, 
  // ...
}
    • 创建postcss配置文件postcss.config.js
// postcss.config.js
module.exports = {
  plugins: [require('postcss-preset-env')]
}
    • 创建 postcss-preset-env 配置文件 .browserslistrc
# 换行相当于 and
last 2 versions # 回退两个浏览器版本
> 0.5% # 全球超过0.5%人使用的浏览器,可以通过 caniuse.com 查看不同浏览器不同版本占有率
IE 10 # 兼容IE 10

【Plugin】:目的在于解决loader无法实现的其他事,从打包优化和压缩,到重新定义环境变量,功能强大到可以用来处理各种各样的任务。webpack提供了很多开箱即用的插件:CommonChunkPlugin主要用于提取第三方库和公共模块,避免首屏加载的bundle文件,或者按需加载的bundle文件体积过大,导致加载时间过长,是一把优化的利器。而在多页面应用中,更是能够为每个页面间的应用程序共享代码创建bundle。

  • ignore-plugin:忽略部分文件
  • clean-webpack-plugin:目录清理。每次执行npm run build会发现dist文件夹里会残留上次打包的文件,可以使用该插件清空文件夹
  • web-webpack-plugin:方便的为单页应用输出html
  • webpack-parallel-uglify-plugin:多进程执行代码压缩,提升构建速度
  • html-webpack-plugin:简化了HTML文件的创建,以便为你的webpack包提供服务 =》依赖于html-loader
  • webpack-bundle-analyzer:需要配合webpack和webpack-cli一起使用,该插件的功能是生成代码分析报告,帮助提升代码质量和网络性能(可视化webpack输出文件的体积)
  • providerplugin:在使用时不再需要import和require进行引入直接使用
  • react-hot-loader:react hmr支持,代码更新不触发导航栏级别刷新

资源模块的使用

webpack5新增资源模块(asset module),允许使用资源文件(字体,图标等)而无需配置额外的loader

资源模块支持以下四个配置:

  1. asset/resource 将资源分割为单独的文件,并导出 url,类似之前的 file-loader 的功能.
  2. asset/inline 将资源导出为 dataUrl 的形式,类似之前的 url-loader 的小于 limit 参数时功能.
  3. asset/source 将资源导出为源码(source code). 类似的 raw-loader 功能
  4. asset 会根据文件大小来选择使用哪种类型,当文件小于 8 KB(默认) 的时候会使用 asset/inline,否则会使用 asset/resource

webpack核心流程解析(模块打包运行原理)

[万字总结] 一文吃透 Webpack 核心原理

将各种类型的资源,包括图片、css、js等,转译、组合、拼接、生成 JS 格式的 bundler 文件

整个过程核心完成了内容转换+资源合并,分为三个阶段:

  1. 初始化阶段
    1. 初始化参数:从配置文件、 配置对象、Shell 参数中读取,与默认配置结合得出最终的参数
    2. 创建编译器对象:用上一步得到的参数创建 Compiler 对象
    3. 初始化编译环境:包括注入内置插件、注册各种模块工厂、初始化 RuleSet 集合、加载配置的插件等
    4. 开始编译:执行 compiler 对象的 run 方法
    5. 确定入口:根据配置中的 entry 找出所有的入口文件,调用 compilation.addEntry 将入口文件转换为 dependence 对象
  1. 构建阶段
    1. 编译模块(make) :根据 entry 对应的 dependence 创建 module 对象,调用 loader 将模块转译为标准 JS 内容,调用 JS 解释器将内容转换为 AST 对象,从中找出该模块依赖的模块,再 递归 本步骤直到所有入口依赖的文件都经过了本步骤的处理
    2. 完成模板编译:上一步递归处理所有能触达到的模块后,得到了每个模块被翻译后的内容以及它们之间的 依赖关系图
  1. 生成阶段:
    1. 输出资源(seal): 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
    2. 写入文件系统:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

compiler:该对象代表了完整的webpack环境配置。编译管理器,webpack启动后会创建compiler对象,该对象一直存活直到结束退出

compilation:单次编辑过程的管理器,比如 watch = true 时,运行过程中只有一个 compiler 但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象

其中文件的解析与构建是一个比较复杂的过程,在webpack源码中主要依赖于compiler和compilation两个核心对象实现

compiler对象是一个全局单例,他负责把控整个webpack打包的构建流程。 compilation对象是每一次构建的上下文对象,它包含了当次构建所需要的所有信息,每次热更新和重新构建,compiler都会重新生成一个新的compilation对象,负责此次更新的构建过程。而每个模块间的依赖关系,则依赖于AST语法树。每个模块文件在通过Loader解析完成之后,会通过acorn库生成模块代码的AST语法树,通过语法树就可以分析这个模块是否还有依赖的模块,进而继续循环执行下一个模块的编译解析。

dependence:依赖对象,webpack 基于该类型记录模块间依赖关系

module:webpack 内部所有资源都会以“module”对象形式存在,所有关于资源的操作、转译、合并都是以 “module” 为基本单位进行的

chunk:编译完成准备输出时,webpack 会将 module 按特定的规则组织成一个一个的 chunk,这些 chunk 某种程度上跟最终输出一一对应

Webpack 实际上为每个模块创造了一个可以导出和导入的环境,本质上并没有修改 代码的执行逻辑,代码执行顺序与模块加载顺序也完全一致。

整个过程中webpack会通过发布订阅模式,向外抛出一些hooks,而webpack的插件即可通过监听这些关键的事件节点,执行插件任务进而达到干预输出结果的目的。

webpack中提高效率的插件

  • webpack-dashboard:更友好的展示相关打包信息
  • size-plugin:监控资源体积变化,尽早发现问题
  • hotmodulereplacementplugin:模块热替换
  • speed-measure-webpack-plugin:分析webpack打包过程中loader和plugin耗时
  • webpack-merge:提取公共配置,减少重复配置代码

热更新

模块热替换功能在应用程序运行过程中,替换,添加或者删除模块无需重新加载整个页面我们在使用的时候,将devServer的hot属性设置为true即可

热加载:文件更新时自动刷新我们的服务和界面,将devServer的liveReload设置为true即可

HMR功能对js的处理只能处理非入口js文件的其他文件,因为入口文件一旦改变,其他文件重新引入就会重新加载,这是没办法阻止的

客户端从服务端拉取更新后的文件,就是代码块需要更新的那部分,实际上wds与浏览器之间维护了一个websocket,当本地资源发生变化时,wds会向浏览器推送更新,并且带上构建时的hash,让客户端与上一次资源进行比对,客户端对比出差异后会向wds发起ajax请求来获取更改内容(文件内容,hash),这样客户端就可以借助这些信息继续向wds发起jsonp请求来获取chunk的增量更新。后续由hotmoduleplugin来完成后续工作,比如拿到增量更新如何处理,那些状态应该保留,哪些又需要更新;hotmoduleplugin提供了相关api供开发者针对自身场景进行处理。

wds:基于node.js的使用了express的http服务器

在dev-server中设置了contentBase的目的

在webpack打包的过程中,对静态文件的处理,例如图片,都是直接 copy 到 dist 目录下面。但是对于本地开发来说,这个过程太费时,也没有必要,所以在设置 contentBase 之后,就直接到对应的静态目录下面去读取文件,而不需对文件做任何移动,节省了时间和性能开销。

本文使用的 webpack-dev-server 版本是 3.11.2,当版本 version >= 4.0.0 时,需要使用 [devServer.static] 进行配置,不再有 devServer.contentBase 配置项。

 devServer: {
    contentBase: path.resolve(__dirname, 'public'),
 }

tree-shaking

面试官:tree-shaking的原理是什么? - 掘金

Webpack 原理系列九:Tree-Shaking 实现原理 - 掘金

开启production环境自动tree-shaking

Tree-shaking:移除js上下文中的未引用代码。是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾其它模块使用,并将其删除,以此实现打包产物的优化。

  • 使用es6语法(模块)
  • 在package.json文件中,添加sideEffects入口,用于标识哪些文件有副作用,有副作用的就不会被删除sideEffects:false所有代码都没有副作用
  • 引入一个能够删除未引用代码(dead code)的压缩工具(minifier)(例如 UglifyJSPlugin)。

在webpack中,启动treeshaking需要满足三个条件:

  • 使用 ESM 规范编写模块代码
  • 配置 optimization.usedExports 为 true,启动标记功能
  • 启动代码优化功能,可以通过如下方式实现:
    • 配置 mode = production
    • 配置 optimization.minimize = true
    • 提供 optimization.minimizer 数组

tree-shaking步骤:

  1. 标记出模块导出值中哪些没有被用过
  • Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
  • Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
  • 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
  1. 使用 Terser 删掉这些没被用到的导出语句

Source map是什么?生产环境怎么用?

source map 是将编译、打包、压缩后的代码映射回源代码的过程。打包压缩后的代码不具备良好的可读性,想要调试源码就需要 soucre map。

devtool配置:

const config = {
  entry: './src/index.js', // 打包入口地址
  output: {
    filename: 'bundle.js', // 输出文件名
    path: path.join(__dirname, 'dist'), // 输出文件目录
  },
  devtool: 'source-map',
  module: { 
     // ...
  }
}
// 打包后,dist目录下会生成以.map结尾的SourceMap文件
dist                   
├─ avatard4d42d52.png  
├─ bundle.js           
├─ bundle.js.map     
└─ index.html     

线上环境一般有三种处理方案:

  1. hidden-source-map:借助第三方错误监控平台 Sentry 使用
  2. nosources-source-map:只会显示具体行数以及查看源代码的错误栈。安全性比 sourcemap 高
  3. sourcemap:通过 nginx 设置将 .map 文件只对白名单开放(公司内网)

三种hash值

  • hash:任何一个文件变动,整个项目的构建hash值都会改变
  • chunkhash:文件的改动只会影响其所在的chunk的hash值
  • contenthash:每个文件都有单独的 hash 值,文件的改动只会影响自身的 hash 值;

文件监听原理

在发现源码发生变化时,自动重新构建出新的输出文件。

Webpack开启监听模式,有两种方式:

  • 启动 webpack 命令时,带上 --watch 参数
  • 在配置 webpack.config.js 中设置 watch:true

缺点:每次需要手动刷新浏览器

原理:轮询判断文件的最后编辑时间是否变化,如果某个文件发生了变化,并不会立刻告诉监听者,而是先缓存起来,等 aggregateTimeout 后再执行。

文件指纹是什么?怎么用?

文件指纹是打包后输出的文件名的后缀

  • hash:和整个项目的构建相关,只要项目文件有修改,整个项目构建的hash值就会更改
  • chunkhash:和webpack打包的chunk有关,不同的entry会生出不同的chunkhash
  • contenthash:根据文件内容来定义hash,文件内容不变,则contenthash不变

指纹设置:

  1. js的文件指纹设置:设置output的filename,用chunkhash
  2. css的文件指纹设置:设置 MiniCssExtractPlugin 的 filename,使用 contenthash。
  3. 图片的文件指纹设置:设置file-loader的name,使用hash

chunk&bundle

Webpack 理解 Chunk - 掘金

Chunk是Webpack打包过程中,一堆module的集合。我们知道Webpack的打包是从一个入口文件开始,也可以说是入口模块,入口模块引用这其他模块,模块再引用模块。Webpack通过引用关系逐个打包模块,这些module就形成了一个Chunk。

bundle:我们最终输出的一个或多个打包文件

产生chunk的三种途径

  1. entry入口
    1. 传递字符串,数组都只会产生一个chunk
    2. 传递对象:一个字段产生一个chunk吗所以在output中filename直接写死名称,会报错
  1. 异步加载模块
  2. 代码分割

优化resolve配置

alias

alias用的创建import或require的别名。用来简化模块引用,项目中基本都需要进行配置

extensions

webpack默认配置

const config = {
  //...
  resolve: {
    extensions: ['.js', '.json', '.wasm'],
  },
};

那么webpack就会按照extensions配置的数组从左到右的顺序去尝试解析模块,需要注意的是:

  1. 高频文件后缀名放前面;
  2. 手动配置后,默认配置会被覆盖

如果想保留默认配置,可以用 ... 扩展运算符代表默认配置

const config = {
  //...
  resolve: {
    extensions: ['.ts', '...'], 
  },
};

modules

告诉 webpack 解析模块时应该搜索的目录

告诉 webpack 优先 src 目录下查找需要解析的文件,会大大节省查找时间

const path = require('path');
// 路径处理方法
function resolve(dir){
  return path.join(__dirname, dir);
}
const config = {
  //...
  resolve: {
     modules: [resolve('src'), 'node_modules'],
  },
};

externals

externals配置选项提供了从输出的bundle中排除依赖的方法,此功能通常对library开发人员来说是最有用的

vite

特点:

  • 冷服务启动:没有打包过程,使用模块模式es6的import。
  • 热更新:即时预览
  • 按需加载当前页面所需文件,一个文件一个http请求,进一步减少启动时间
  • 按需进行编译,不会刷新全部dom
  • ssr支持

缺点:

  • 兼容性不好,无论是 dev 还是 build 都会直接打出 ESM 版本的代码包,这就要求客户浏览器需要有一个比较新的版本
  • 缺少show case
  • 代价:对于vite开发团队来说,维护成本较高,对于开发者来说,随着项目复杂度的提高需要了解esbuild和rollup

注:

  • vite主要应对的场景是开发模式,原理是拦截浏览器发出的ES imports请求并作出i相应处理(生产模式是用rollup打包)
  • vite在开发模式下不需要打包,只需呀编译浏览器发出的http请求对应的文件即可,所以热更新速度很快

打包的时候使用的rollup的这个打包器,以将小块代码编译成大块复杂的代码。Rollup 对代码模块使用新的标准化格式

ESbuild 是一个类似webpack构建工具。它的构建速度是 webpack 的几十倍。

vite的性能优化:

  • 预编译:npm这类不会变得模块,使用esbuild在预构建阶段先打包整理好,减少http请求数
  • 按需编译:用户代码这一类频繁变动的模块,直到被使用时才会执行编译操作
  • 客户端强缓存:请求过的模块会被以 http 头 max-age=31536000,immutable 设置为强缓存,如果模块发生变化则用附加的版本 query 使其失效
  • vite尽量避免直接处理静态资源。例如引入图片 import img from 'xxx.png' 语句,执行后 img 变量只是一个路径字符串

热更新

webpack:打包。一旦发生某个依赖,就将这个依赖所处的module更新,并将新的module发送给浏览器重新执行。由于只打了一个bundle.js所以热更新也会重打这个bundle.js.如果依赖越来越多,就算只修改一个文件,理论上热更新的速度也会越来越慢。

vite:只编译不打包。 最终产出的还是分离的分拣,只有编译耗时,当浏览器解析到import的时候,会发起http请求,从而达到不打包也可以加载所需代码的目的。在热更新的时候,如果某个文件发生了改变,只需要更新其本身和用到该文件的文件即可,对于没有发生改变的无需重新编译,直接从缓存中获取结果,所以理论上热更新的速度不会变慢

vite是通过websocket实现的热更新

  • 创建一个websocket服务端
  • 创建一个ws client文件,并在html中引入,加载ws client文件
  • 服务的短监听文件变化,发送websocket消息,告诉客户端变化类型,变化文件等
  • 客户端接收消息,根据消息内容决定重新刷新页面还是重新加载变化文件,并执行相关文件注入ws client时设置的热更新回调函数

npm

npm run dev都发生了什么

  • npm run执行了package.json中的script脚本,对应的就是package.json中scripts属性的dev值:如果是使用的vue-cli,dev对应的值为vue-cli-service dev使用的webpack,对应的是webpack-dev-server --inline --progress --config build/webpack.dev.conf.js
  • 该dev值对应的也是一个命令,会执行当前目录显得node_modules/.bin/vue-cli-service.cmd,vue-cli-service.cmd这个文件又会用node执行@vue\cli-service\bin\vue-cli-service.js文件
  • service\bin\vue-cli-service.js这个文件里加载这对应的命令处理文件,会发现加载了webpack-dev-server这个包(实际上是基于express实现的)

整个的执行链条npmrundev->vue-cli-serviceserve->webpack-dev-server->express->node->http

所以我们在npm run dev后再localhost会运行,实际上就是调用了web服务器

npm缺点

npm存在幽灵依赖

在我们的项目中使用npm时,依赖每次被不同的项目使用,都会重复安装一次,这不仅浪费了我们大量的摸鱼时间,而且还占据了大量的内存,这必然造成空间的浪费

pnpm(performant npm)

vue配套生态已经全面使用pnpm了,你再不学就说不过去了!🤣🤣🤣 - 掘金

当我们使用npm进行不同的项目开发的时候,依赖都会重复装一次,而在使用pnpm时,依赖会被存在内容可寻址的存储中

  • 如果你用到了某依赖项的不同版本,只会将不同版本间有差异的文件添加到仓库。例如,如果某个包有100个文件,而它的新版本只改变了其中1个文件。那么 pnpm update 时只会向存储中心额外添加1个新文件,而不会因为仅仅一个文件的改变复制整新版本包的内容。
  • 所有文件都会存储在硬盘上的某一位置,当软件包被被安装时,包里的文件会硬链接到这一位置,而不会占用额外的磁盘空间,这允许你跨项目地共享同一版本的依赖。

优点:

  • 快速:pnpm 比其他包管理工具快两倍
  • 高效:node_modules 中的文件链接自特定的内容寻址存储库
  • 支持monorepo:pnpm 内置了对存储库中的多个包的支持;
  • 严格:pnpm 默认创建一个非平铺的 node_modules,因此代码不能访问任意包;

管理nodejs版本

pnpm能够管理nodejs版本

// 安装 LTS 版本的 Nodejs
pnpm env use --global lts
// 安装 v16 的Node.js:
pnpm env use --global 16

软连接&硬连接

包是从全局 store 硬连接到虚拟 store 的,这里的虚拟 store 就是 node_modules/.pnpm,包和包之间的依赖关系是通过软链接组织的。

软连接:符号链接也称为软链接,它是一类特殊的文件,其包含有一条以绝对路径或者相对路径的形式执行其他文件或者目录的引用。一个符号链接文件仅包含有一个文本字符串,其被操作系统解释为一条指向另一个文件或者目录的路径。它是一个独立文件,其存在并不依赖于目标文件。如果删除一个符号链接,它指向的目标文件不受影响,如果目标文件被移动、重命名或者删除,任何指向它的符号链接仍然存在,但是它们将会指向一个不复存在的文件。

硬连接:计算机文件系统中的多个文件平等地共享同一个文件存储单元。硬链接必须在同一个文件系统中;一般用户权限下的硬链接只能用于文件,不能用于目录,因为其父目录就有歧义了。删除一个文件名字后,还可以用其它名字继续访问该文件。硬链接只能用于同一个文件系统,不能用于不存在的文件。

删除不再使用的包

可以执行 pnpm store prune 删除不再被引用的包

babel原理

  • 解析:将代码转换为ast:
    • 词法分析:将代码分割为token流
    • 语法分析:分析token流并生成ast
  • 转换:访问ast的节点进行变换操作生成新的ast
  • 生成:以新的ast为基础生成代码

前端监控

前端监控&埋点

从0到1实现一个前端监控系统(附源码)

一个完整的前端监控平台包括三个部分:数据采集与上报数据分析和存储数据展示

监控的目的:

  • 事先预警:当监控的数据达到阀值时,通知开发人员,避免造成大面积的错误。
  • 事后分析:通过监控信息分析故障原因和故障发生点,防止类似情况再次发生。
  • 性能优化:采集页面关键性能指标,帮助开发者了解页面的性能情况,为页面优化提供方向。
  • 提供决策:通过监控平台采集各个项目的PV,UV,健康状况,性能指标等数据,帮助决策者了解页面的整体运行情况,为决策提供数据支撑。

数据采集与上报

上报方法:

可以优先navigator.sendBeacon,降级使用1x1像素gif图片,根据实际情况需要采用xhr

  • xhr
  • 使用1x1像素的gif图片上报
  • navigator.sendBeacon

navigator.sendBeacon是一个用于发送少量数据到服务器的浏览器API

优点:

  • 异步和非阻塞:navigator.sendBeacon是异步的,他不会阻塞浏览器的其他操作,这对于性能监控来说非常重要,因为都不希望监控的过程影响到页面的性能
  • 在页面卸载时仍然可以发送数据:当用户离开页面(例如关闭页面或者导航到其他页面)时,navigator.sendBeacon仍然可以发送数据。这对于捕获和上报页面卸载前的最后一些性能数据来说非常有用。
  • 低优先级:navigator.sendBeacon发送的请求是低优先级的,他不会影响到页面的其他请求
  • 简单易用:navigator.sendBeacon的API非常建档,只需要提供上报的URL和数据,就可以发送请求

缺点:

  • 只能发送POST请求,不能发送GET请求
  • 它发送的请求没有返回值,不能接收服务器的响应

上报时机:

先缓存上报数据,缓存到一定数量后,利用 requestIdleCallback/setTimeout 延时上报。在页面离开时统一将未上报的数据进行上报

性能优化

http优化

  • 减少请求次数
  • 减少单次请求所花费的时间

webpack的优化方案

webpack的优化瓶颈主要是:

  • webpack的构建过程太花时间
    • 不要让loader做太多事情
      Eg: 使用babel-loader时:
      • 用include或exclude避免不必要的转译
      • 开启缓存将转译结果缓存至文件系统
loader: babel-loader?cacheDirectory=true
    • 处理第三方库
      • externals:防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖。
      • CommonsChunkPlugin每次构建时都会重新构建一次vendor(第三方模块)。用于提取第三方库和公共模块,避免首屏加载的bundle文件或者按需加载的bundle文件体积过大从而导致加载事件过长
      • DllPlugin:该插件会将第三方库单独打包到一个文件中,这个文件就是一个单纯的依赖库,这个依赖库不会跟着业务代码一起被重新打包,只有当依赖自身发生版本变化时才会重新打包
        • 基于dll专属的配置文件,打包dll库
        • 基于webpack.config.js,打包业务代码
    • Happypack:将loader由单进程转为多进程
  • webpack打包的结果体积太大
    • 文件结构可视化,找到导致体积过大的原因
      webpack-bundle-analyzer:以矩形树图的形式将包内各个模块的大小和依赖关系呈现出来
    • 拆分资源
    • 删除冗余代码,例如tree-shaking,UglifyJsPlugin
    • 按需加载:Code-Splitting是React-Router的按需加载的事件,Bundle-Loader的源代码使用require-ensure实现的,require-ensure会确定跳转是对应的路由才会加载对应内容

Gzip压缩原理

  1. 开启Gzip压缩只需要在request headers中加上accept-encoding:gzip
  2. HTTP压缩是一种内置到网页服务器和网页客户端中以改进传输速度和带宽利用率的方式。在使用 HTTP 压缩的情况下,HTTP 数据在从服务器发送前就已压缩:兼容的浏览器将在下载所需的格式前宣告支持何种方法给服务器;不支持压缩方法的浏览器将下载未经压缩的数据。最常见的压缩方案包括 Gzip 和 Deflate。HTTP 压缩就是以缩小体积为目的,对 HTTP 内容进行重新编码的过程
  3. Gzip的内核就是Deflate,压缩文件用的最多的就是Gzip。Gzip压缩后通常能帮我们减少响应70%左右的大小,但是并不保证对每一个文件的压缩都会使其变小
  4. Gzip是在一个文本文件中找出一些重复出现的字符串,临时替换他们,从而使整个文件变小,文件中代码的重复率越高,则压缩的效率就越高,使用Gzip的收益也就越大
  5. webpack的Gzip和服务端的Gzip

Gzip通常是服务器的活,所以我们是以服务器压缩时间的开销和CPU开销为代价,省下了一些传输过程中的时间开销如果存在大量的压缩需求,服务器也扛不住的。服务器一旦因此慢下来了,用户还是要等。Webpack 中 Gzip 压缩操作的存在,事实上就是为了在构建过程中去做一部分服务器的工作,为服务器分压。

图片优化

  1. httparchive.org/reports/pag… Api的使用情况等页面的详细信息,并会对这些数据进行处理和分析以确定趋势
  2. 在计算机中,像素用二进制数表示,不同的图片格式中像素与二进数位数之间的对应关系是不同的,一个像素对应的二进制位数越多,它可以表示的颜色种类越多,成像效果越细腻,文件体积相应也会更大。一个二进制位表示两种颜色0|1对应黑白,如果一种图片格式对应的二进制位数有 n 个,那么它就可以呈现 2^n 种颜色。
  3. 图片格式对比
    • JPG/JPEG:有损压缩,体积小,加载快,不支持透明
      • 当我们把图片体积压缩至原有体积的 50% 以下时,JPG 仍然可以保持住 60% 的品质。
      • JPG格式以24位存储单个图,可以呈现1600万种颜色,可以应对大多数场景对颜色的要求
      • 在开发过程中,JPG图片经常作为大的背景图,轮播图或Banner图出现
    • PNG-8与PNG-24(可移植网络图形格式):无损压缩,质量高,体积大,支持透明
      • 这里的8和24指的是二进制数的位数。8 位的 PNG 最多支持 256 种颜色,而 24 位的可以呈现约 1600 万种颜色。
      • 常用于呈现小的Logo,颜色简单并且对比强烈的图片或者背景
    • SVG(可缩放矢量图形):文本文件,体积小,不失真,兼容性好
      • 是一种基于XMl语法的图像格式,SVG对图像的处理不是基于像素点是基于对图像的形状描述
      • 可以写在HTML里或者以.svg为后缀的独立文件
    • Base64:文本文件,依赖编码,小图标解决方案
      • Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,我们可以直接将编码结果写入 HTML 或者写入 CSS,从而减少 HTTP 请求的次数。
      • Base64编码后,图片大小会膨胀为原文件的 4/3,如果我们把大图也编码到 HTML 或 CSS 文件中,后者的体积会明显增加,即便我们减少了 HTTP 请求,也无法弥补这庞大的体积带来的性能开销,得不偿失,所以我们不使用Base64解决大图片
      • 使用Base64的场景:
        • 图片的实际尺寸很小(大家可以观察一下掘金页面的 Base64 图,几乎没有超过 2kb 的)
        • 图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是雪碧图的补充)
        • 图片的更新频率非常低(不需我们重复编码和修改文件内容,维护成本较低)
      • 可以使用Webpack的url-loader除了具备基本的 Base64 转码能力,还可以结合文件大小,帮我们判断图片是否有必要进行 Base64 编码。

雪碧图(css sprites)—— 小图标解决方案

将小图标和背景图像合并到一张图片上,然后利用 CSS 的背景定位来显示其中的每一部分的技术。Base64是作为雪碧图的补充而存在的

    • WebP:加快图片加载速度,支持有损压缩和无损压缩。
      • 与 PNG 相比,WebP 无损图像的尺寸缩小了 26%。
      • 在等效的 SSIM (结构相似性)质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。
      • 无损 WebP 支持透明度(也称为 alpha 通道),仅需 22% 的额外字节。对于有损 RGB 压缩可接受的情况,有损 WebP 也支持透明度,与 PNG 相比,通常提供 3 倍的文件大小。
      • WebP 还会增加服务器的负担:与编码JPG文件相比,编码同样质量的Webp文件会占用更多的计算资源

浏览器缓存机制

  1. Memory Cache:内存中的缓存,浏览器最先尝试去命中的缓存,响应速度最快的一种缓存,进程结束后,内存里的数据就不存在了
  2. Service Worker Cache:是一种独立于主线程之外的js线程,它脱离于浏览器窗体,不能直接访问dom。可以帮助实现离线缓存,消息推送和网络代理等功能
    • Service Worker 的生命周期包括 install、active、working 三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。
    • 必须是以HTTPS协议为前提
  1. HTTP Cache
    http缓存决策:当我们的资源内容不可复用时,直接为 Cache-Control 设置 no-store,拒绝一切形式的缓存;否则考虑是否每次都需要向服务器进行缓存有效确认,如果需要,那么设 Cache-Control 的值为 no-cache;否则考虑该资源是否可以被代理服务器缓存,根据其结果决定是设置为 private 还是 public;然后考虑该资源的过期时间,设置对应的 max-age 和 s-maxage 值;最后,配置协商缓存需要用到的 Etag、Last-Modified 等参数。
  2. Push Cache:HTTP2 在 server push 阶段存在的缓存
    • Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。
    • Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放
    • 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache

本地存储

  1. Cookie:cookie指的是某些网站为了识别用户身份而存储在用户本地终端的数据,cookie是服务端生成,客户端进行维护和存储。
    使用场景:用户登录状态类的会话状态管理;自定义设置,主题等个性化设置;跟踪分析用户行为类的浏览器行为跟踪
    • cookie以键值对的形式存在
    • cookie是紧跟域名的,我们通过响应头里的Set-Cookie指定要存储的cookie值,默认情况下,domain被设置为设置cookie页面的主机名,同一个域名下的所有请求都会携带cookie
      缺点:
      • cookie最大只能有 4 KB,只能存取少量的信息,**很多浏览器限制一个站点最多保存20个cookie(ie6或者 更低版本),ie7和之后的版本最多可以有50个cookie,firefox也可以有50个cookie,chrome 和safari没有硬性限制
      • 过多的cookie会带来性能浪费:一旦服务器端向客户端发送了设置cookie的意图,除非cookie过期,否则客户端每次请求都会发送这些cookie到服务器端,一旦设置的cookie过多,将会导致报头较大,大多数的cookie并不需要每次都用上,会造宽带的浪费
        • 减少cookie的大小
        • 为静态组件使用不同的域名

多个域名的优点:

  • 为不需要cookie的组件换个域名可以减少无效cookie的传输,所以很多网站的静态文件会有特别的域名,使得业务相关的cookie不影响静态资源
  • 不仅可以减少cookie的发送,还可以突破浏览器下载线程数量的限制,因为域名不同,下载线程限制数量翻倍

多个域名的缺点:

  • 将域名转换为IP需要进行DNS查询,多一个域名就多一次dns查询,页面的性能规则上就有一条:减少DNS查询,但是大多数浏览器都会进行DNS查询,以削弱这个副作用的影响
      • 不安全,http中 cookie 是明文传递的,所以具有安全问题

限制访问cookie:有两种方法可以确保 Cookie 被安全发送,并且不会被意外的参与者或脚本访问:Secure 属性和 HttpOnly 属性。

      • Secure属性:标记为 Secure 的 Cookie 只应通过被 HTTPS 协议加密过的请求发送给服务端。它永远不会使用不安全的 HTTP 发送(本地主机除外) ,这意味着中间人攻击者无法轻松访问它。不安全的站点(在 URL 中带有 http:)无法使用 Secure 属性设置 cookie。但是,Secure 不会阻止对 cookie 中敏感信息的访问。例如,有权访问客户端硬盘(或,如果未设置 HttpOnly 属性,则为 JavaScript)的人可以读取和修改它。
      • HttpOnly属性:HttpOnly 不支持读写,浏览器不允许脚本操作document.cookie去更改cookie, 所以为避免跨域 脚本 (XSS) 攻击,通过JavaScript的 Document.cookie API无法访问带有 HttpOnly 标记的 Cookie,它们只应该发送给服务端。如果包含服务端 Session 信息的 Cookie 不想被客户端 JavaScript 脚本调用,那么就应该为其设置 HttpOnly 标记。
  1. Web Storage:是html5专门为浏览器存储而提供的数据存储机制,为了解决客户端存储不需要频繁发送回服务器的数据时使用cookie问题
Local StorageSession Storage
生命周期持久化,只能选择手动删除临时性的本地存储,当会话结束或者页面被关闭时,存储内容消失,不受页面刷新影响,可以在浏览器崩溃后重启恢复
作用域同源策略同源策略,但是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 Session Storage 内容便无法共享。
存储容量大,可以达到5-10M,大多数限制为5mb仅位于浏览器端,不与服务器端发生通信存储数据:setItem(name,value) 设置给定name的名/值对读取数据:getItem(name) 取得给定name的值删除某一键名对应的数据:removeItem(name)清空数据记录:clear(),不在firefox实现key(index):取得给定数值位置的名称
    1. 检测某一个网页下localStorage剩余容量
      如何给localStorage设置过期时间]( https://www.jianshu.com/p/50b4c89d3be3 )
      localStorage本身并没有提供过期机制,我们只能自己实现,我们给其原型上添加一个方法,设置的时候就将当前时间记录进去,然后获取值得时候判断一下当前时间和之前的时间差是否在某个范围之内,若果超出范围,则清空当前项,并返回null
Storage.prototype.setExpire=(key,value,expire) =>{  
    let obj={
        data:value, // 实际的值
        time:Date.now(),  // 当前时间戳
        expire:expire  // 过期时间
    };
    // localStorage 设置的值不能为对象, 所以这里使用了 JSON.stringify 
    // 方法将其转为字符串,最终在使用的时候得转回来。
    localStorage.setItem(key,JSON.stringify(obj));
};
Storage.setExpire(key,value,expire);
Storage.prototype.getExpire= key => {
    let val = localStorage.getItem(key);
    if(!val){
      return val;
    }
    val = JSON.parse(val);
    if(Date.now() - val.time > val.expire){
        localStorage.removeItem(key);
        return null;
    }
    return val.data;
}
      • IndexedDB:运行在浏览器上的非关系型数据库,使用对象存储而不是表格保存数据,是网页中的异步API,IndexedDB数据库就是在一个公共命名空间下的一组对象存储,使用IndexedDB数据库的步骤:
    1. 键范围:键范围(key range)可以让游标更容易管理。键范围对应IDBKeyRange的实例。指定键范围的方式:
      • only(想要获取的键):使用这个范围创建的游标类似于直接访问对象存储并调用get(想要获取的键)
      • 定义结果集的下限。下限表示游标开始的位置
      • 定义结果集的上限,通过调用upperBound()方法可以指定游标不会越过的记录。如果不想包含指定的键,可以在第二个参数传入true:
      • 同时指定下限和上限,可以使用bound()方法。这个方法接收四个参数:下限的键、上限的键、可选的布尔值表示是否跳过下限和可选的布尔值表示是否跳过上限
    1. 并发问题
    2. 限制
      • IndexedDB没有存储上限,一般不会小于250M,可以存储字符串和二进制数据

CDN的缓存与回源机制

  1. CDN(内容分发网络)指的是一组分布在各个地区的服务器,这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响。cdn会通过一个遍布全球的服务器网络来分发缓存的静态内容。
  2. CDN往往被用来存放静态资源(不需要业务服务器进行计算即得的资源)
  3. 动态资源:后端实时动态生成的资源,较为常见的就是 JSP、ASP 或者依赖服务端渲染得到的 HTML 页面。

非纯静态资源:需要服务器在页面之外作额外计算的 HTML 页面

服务端渲染&客户端渲染

客户端渲染模式下,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM;

服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。

浏览器性能优化

  1. 浏览器内核可以分成两部分:渲染引擎和Js引擎,后期内核逐渐演化成渲染引擎的代称
  2. 常见的浏览器内核:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari)
  3. 渲染过程:HTML/css/js资源=》浏览器内核=》图像。
    浏览器执行所有的加载解析逻辑,在解析HTNL的过程中发出了页面渲染所需的各种外部资源请求;浏览器将识别并加载所有的css 样式信息与 DOM 树合并,最终生成页面 render 树(:after :before 这样的伪元素会在这个环节被构建到 DOM 树中);计算页面中所有元素的相对位置信息,大小等信息;浏览器会根据我们的 DOM 代码结果,把每一个页面图层转换为像素,并对所有的媒体文件进行解码。最后浏览器会合并合各个图层,将数据由 CPU 输出给 GPU 最终绘制在屏幕上。
    在这其中需要关注的模块
    1. HTML解释器:将 HTML 文档经过词法分析输出 DOM 树
    2. CSS 解释器:解析 CSS 文档, 生成样式规则。
    3. 图层布局计算模块:布局计算每个对象的精确位置和大小
    4. 视图绘制模块:进行具体节点的图像绘制,将像素渲染到屏幕上
    5. JavaScript 引擎:编译执行 Javascript 代码。
  1. HTML,css与js都具有阻塞渲染的特性:有了html才能有dom;浏览器在构建cssom的时候不会渲染任何已处理的内容,所以需要将css往前放
  2. 从应用的角度来说,一般当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer。
  3. 当我们需要在异步任务中实现Dom修改时,把它包装成micro任务相对明智,因为结束了对 script 脚本的执行,是不是紧接着就去处理 micro-task 队列了?micro-task 处理完,DOM 修改好了,紧接着就可以走 render 流程了——不需要再消耗多余的一次渲染,不需要再等待一轮事件循环,直接为用户呈现最即时的更新结果。如果放在macro队列里面,需要等下一次事件循环

重绘和回流

  1. 导致回流的原因:
    • 改变dom元素的几何属性
    • 改变dom树的结构:节点的增加,移动等
    • 获取一些特定属性的值:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight ,getComputedStyle()这些值需要通过即时计算得到,所以为了获取这些值也会发生回流

解决方案:

    • 避免频繁改动:以 JS 变量的形式缓存起来,待计算完毕再提交给浏览器发出重计算请求
    • 避免逐条改变样式,使用类名去合并样式
    • DOM离线化:一旦我们给元素设置 display: none,将其从页面上“拿掉”,那么我们的后续操作,将无法触发回流与重绘
  1. Flush队列
    浏览器缓存了一个flush队列,把我们触发的回流与重绘任务都塞进去,等到队列里的任务多起来,或者达到一定的时间间隔,或者不得已的时候再将这些任务一口气出队,所以进行多次更改的时候会只触发一次layout和一次paint

优化首屏体验

  1. lazyLoad
<!DOCTYPE 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>Lazy-Load</title>
  <style>.img {
      width: 200px;
      height:200px;
      background-color: gray;
    }
    .pic {
      // 必要的img样式
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="img">
      // 注意我们并没有为它引入真实的src
      <img class="pic" alt="加载中" data-src="./images/1.png">
    </div>
    <div class="img">
      <img class="pic" alt="加载中" data-src="./images/2.png">
    </div>
    ...n个div
  </div>
</body>
</html>
<script>
    // 获取所有图片标签
    const imgs = document.getElementsByTagName('img');
    // 获取可视区域高度
    const viewHeight = window.innerHeight || document.documentElement.clientHeight
    // num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
    let num = 0
    function lazyLoad(){
        for(let i = num;i < imgs.length;i++){
            // 用可视区域高度减去元素顶部距离可视区域顶部的高度
            let distance = viewHeight - imgs[i].getBoundingClientRect().top
            // 如果可视区域高度大于等于元素顶部距离可视区域顶部的高度,说明元素露出
             if(distance >= 0 ){
                // 给元素写入真实的src,展示图片
                imgs[i].src = imgs[i].getAttribute('data-src')
                // 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
                num = i + 1
            }
        }
    }    
    // 监听Scroll事件
    window.addEventListener('scroll', lazyload, false);
</script>

window.innerHeight:获取当前可视区域高度(现代浏览器及IE9以上的浏览器)

document.documentElement.clientHeight:低版本IE的标准模式中获取

getBoundingClientRect():获取返回元素的大小及其相对于视口的位置。

performance,lightHouse与性能Api

  1. FPS:这是一个和动画性能密切相关的指标,它表示每一秒的帧数。图中绿色柱状越高表示帧率越高,体验就越流畅。若出现红色块,则代表长时间帧,很可能会出现卡顿。图中以绿色为主,偶尔出现红块,说明网页性能并不糟糕,但仍有可优化的空间。
  2. CPU:表示CPU的使用情况,不同的颜色片段代表着消耗CPU资源的不同事件类型。这部分的图像和下文详情面板中的Summary内容有对应关系,我们可以结合这两者挖掘性能瓶颈
  3. NET:粗略的展示了各请求的耗时与前后顺序。这个指标一般来说帮助不大。

虚拟列表

「前端进阶」高性能渲染十万条数据(虚拟列表) - 掘金

假设我们的长列表需要展示10000条记录,我们同时将10000条记录渲染到页面中。此时为了避免卡顿就可以使用虚拟列表。虚拟列表就是为了同时加载大量数据

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

实现

在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。

  • 计算当前可视区域起始数据索引
  • 计算当前可视区域结束数据索引
  • 计算当前可视区域的数据,并渲染到页面中
  • 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上

// 可视区域的容器
<div class="infinite-list-container">
    //  容器内的占位,高度为总列表高度,用于形成滚动条   
  <div class="infinite-list-phantom"></div>
  	// 列表项的渲染区域
    <div class="infinite-list">
      <!-- item-1 -->
      <!-- item-2 -->
      <!-- ...... -->
      <!-- item-n -->
    </div>
</div>

接着监听infinite-list-container的scroll时间,获取滚动位置scrollTop

  • 假定可视区域高度固定,称之为screenHeight
  • 假定列表每项高度固定,称之为itemSize
  • 假定列表数据称之为listData
  • 假定当前滚动位置称之为scrollTop

则可得到:

  • 列表总高度:listHeight = listData.length * itemSize
  • 可现实的列表项数:visibleCount = Math.ceil(screenHeight / itemSize)
  • 数据的起始索引:startIndex = Math.floor(scrollTop / itemSize)
  • 数据的结束索引:endIndex = startIndex + visibleCount
  • 列表显示数据为:visibleData = listData.slice(startIndex,endIndex)

当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。

  • 偏移量startOffset = scrollTop - (scrollTop % itemSize);
<template>
  <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items"
        class="infinite-list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
        >{{ item.value }}</div>
    </div>
  </div>
</template>
export default {
  name:'VirtualList',
  props: {
    //所有列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    //每项高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表总高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可显示的列表项数
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量对应的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //获取真实显示列表数据
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  data() {
    return {
      //可视区域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //结束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //当前滚动位置
      let scrollTop = this.$refs.list.scrollTop;
      //此时的开始索引
      this.start = Math.floor(scrollTop / this.itemSize);
      //此时的结束索引
      this.end = this.start + this.visibleCount;
      //此时的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
};

可以使用IntersectionObserver替换监听scroll事件,IntersectionObserver可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。

为了避免滚动过快的时候会出现短暂的白屏的现象的出现,可以在可见区域的上方和下方渲染额外的项目,在滚动时给予一些缓冲,所以将屏幕分为三个区域:

  • 可视区域上方: above
  • 可视区域:screen
  • 可视区域下方:below