10w字!前端知识体系+大厂面试笔记(工程化篇)

点击上方 前端Q,关注公众号

回复加群,加入前端Q技术交流群

本文为读者投稿的系列文章(三),内容超肝,慎入!

历时8个月,10w字!前端知识体系+大厂面试总结(基础知识篇)

作者主页:

https://juejin.cn/user/2594503172831208

正文

工程化目的是为了提升团队的开发效率、提高项目的质量

例如大家所熟悉的构建工具、性能分析与优化、组件库等知识,都属于工程化的内容

这篇文章的内容,是我这几年对工程化的实践经验与收获总结

文中大部分的内容,主要是以 代码示例 + 分析总结 + 实践操作 来讲解的,始终围绕实用可操作性来说明,争取让小伙伴们更容易理解和运用

看十篇讲 webpack 的文章,可能不如手写一个 mini 版的 webpack 来的透彻

工程化是一个优秀工程师的必修课,也是一个重要的分水岭

10w字!前端知识体系+大厂面试笔记(工程化篇)_第1张图片

前端工程化导图

10w字!前端知识体系+大厂面试笔记(工程化篇)_第2张图片 前端工程化.png

构建工具

Webpack

Webpack是前端最常用的构建工具,重要程度无需多言

之前看过很多关于 Webpack 的文章,总是感觉云里雾里,现在换一种方式,我们一起来解密它,尝试打开这个盲盒

手写一个 mini 版的 Webpack

别担心

我们不需要去掌握具体的实现细节,而是通过这个案例,了解 webpack 的整体打包流程,明白这个过程中做了哪些事情,最终输出了什么结果即可

创建minipack.js
const fs = require('fs');
const path = require('path');
// babylon解析js语法,生产AST 语法树
// ast将js代码转化为一种JSON数据结构
const babylon = require('babylon');
// babel-traverse是一个对ast进行遍历的工具, 对ast进行替换
const traverse = require('babel-traverse').default;
// 将es6 es7 等高级的语法转化为es5的语法
const { transformFromAst } = require('babel-core');

// 每一个js文件,对应一个id
let ID = 0;

// filename参数为文件路径, 读取内容并提取它的依赖关系
function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8');

  // 获取该文件对应的ast 抽象语法树
  const ast = babylon.parse(content, {
    sourceType: 'module'
  });

  // dependencies保存所依赖的模块的相对路径
  const dependencies = [];

  // 通过查找import节点,找到该文件的依赖关系
  // 因为项目中我们都是通过 import 引入其他文件的,找到了import节点,就找到这个文件引用了哪些文件
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      // 查找import节点
      dependencies.push(node.source.value);
    }
  });

  // 通过递增计数器,为此模块分配唯一标识符, 用于缓存已解析过的文件
  const id = ID++;
  // 该`presets`选项是一组规则,告诉`babel`如何传输我们的代码.
  // 用`babel-preset-env`将代码转换为浏览器可以运行的东西.
  const { code } = transformFromAst(ast, null, {
    presets: ['env']
  });

  // 返回此模块的相关信息
  return {
    id, // 文件id(唯一)
    filename, // 文件路径
    dependencies, // 文件的依赖关系
    code // 文件的代码
  };
}

// 我们将提取它的每一个依赖文件的依赖关系,循环下去:找到对应这个项目的`依赖图`
function createGraph(entry) {
  // 得到入口文件的依赖关系
  const mainAsset = createAsset(entry);
  const queue = [mainAsset];
  for (const asset of queue) {
    asset.mapping = {};
    // 获取这个模块所在的目录
    const dirname = path.dirname(asset.filename);
    asset.dependencies.forEach((relativePath) => {
      // 通过将相对路径与父资源目录的路径连接,将相对路径转变为绝对路径
      // 每个文件的绝对路径是固定、唯一的
      const absolutePath = path.join(dirname, relativePath);
      // 递归解析其中所引入的其他资源
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;
      // 将`child`推入队列, 通过递归实现了这样它的依赖关系解析
      queue.push(child);
    });
  }

  // queue这就是最终的依赖关系图谱
  return queue;
}

// 自定义实现了require 方法,找到导出变量的引用逻辑
function bundle(graph) {
  let modules = '';
  graph.forEach((mod) => {
    modules += `${mod.id}: [
      function (require, module, exports) { ${mod.code} },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });
  const result = `
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];
        function localRequire(name) {
          return require(mapping[name]);
        }
        const module = { exports : {} };
        fn(localRequire, module, module.exports);
        return module.exports;
      }
      require(0);
    })({${modules}})
  `;
  return result;
}

// ❤️ 项目的入口文件
const graph = createGraph('./example/entry.js');
const result = bundle(graph);

// ⬅️ 创建dist目录,将打包的内容写入main.js中
fs.mkdir('dist', (err) => {
  if (!err)
    fs.writeFile('dist/main.js', result, (err1) => {
      if (!err1) console.log('打包成功');
    });
});

mini版的Webpack未涉及 loader 和 plugin 等复杂功能,只是一个非常简化的例子

mini 版的 webpack 打包流程

1)从入口文件开始解析
2)查找入口文件引入了哪些 js 文件,找到依赖关系
3)递归遍历引入的其他 js,生成最终的依赖关系图谱
4)同时将 ES6 语法转化成 ES5
5)最终生成一个可以在浏览器加载执行的 js 文件

创建测试目录 example

在目录下创建以下 4 个文件

1)创建入口文件entry.js

import message from './message.js';
// 将message的内容显示到页面中
let p = document.createElement('p');
p.innerHTML = message;
document.body.appendChild(p);

2)创建message.js

import { name } from './name.js';
export default `hello ${name}!`;

3)创建name.js

export const name = 'Webpack';

4)创建index.html



  
    
    Webpack App
  
  
  
  

5) 执行打包

运行node minipack.js,dist 目录下生成 main.js

6) 浏览器打开 index.html

页面上显示hello Webpack!

mini-webpack 的 github 源码地址[1]

分析打包生成的文件

分析dist/main.js

文件内容

1)文件里是一个立即执行函数

2)该函数接收的参数是一个对象,该对象有 3 个属性
0 代表entry.js;
1 代表message.js;
2 代表name.js

dist/main.js 代码

// 文件里是一个立即执行函数
(function(modules) {
  function require(id) {
    const [fn, mapping] = modules[id];
    function localRequire(name) {
      // ⬅️ 第四步 跳转到这里 此时mapping[name] = 1,继续执行require(1)
      // ⬅️ 第六步 又跳转到这里 此时mapping[name] = 2,继续执行require(2)
      return require(mapping[name]);
    }
    // 创建module对象
    const module = { exports: {} };
    // ⬅️ 第二步 执行fn
    fn(localRequire, module, module.exports);

    return module.exports;
  }
  // ⬅️ 第一步 执行require(0)
  require(0);
})({
  // 立即执行函数的参数是一个对象,该对象有3个属性
  // 0 代表entry.js;
  // 1 代表message.js
  // 2 代表name.js
  0: [
    function(require, module, exports) {
      'use strict';
      // ⬅️ 第三步 跳转到这里 继续执行require('./message.js')
      var _message = require('./message.js');
      // ⬅️ 第九步 跳到这里 此时_message为 {default: 'hello Webpack!'}
      var _message2 = _interopRequireDefault(_message);

      function _interopRequireDefault(obj) {
        return obj && obj.__esModule ? obj : { default: obj };
      }

      var p = document.createElement('p');
      // ⬅️ 最后一步 将_message2.default: 'hello Webpack!'写到p标签中
      p.innerHTML = _message2.default;
      document.body.appendChild(p);
    },
    { './message.js': 1 }
  ],
  1: [
    function(require, module, exports) {
      'use strict';

      Object.defineProperty(exports, '__esModule', {
        value: true
      });
      // ⬅️ 第五步 跳转到这里 继续执行require('./name.js')
      var _name = require('./name.js');
      // ⬅️ 第八步 跳到这里 此时_name为{name: 'Webpack'}, 在exports对象上设置default属性,值为'hello Webpack!'
      exports.default = 'hello ' + _name.name + '!';
    },
    { './name.js': 2 }
  ],
  2: [
    function(require, module, exports) {
      'use strict';

      Object.defineProperty(exports, '__esModule', {
        value: true
      });
      // ⬅️ 第七步 跳到这里 在传入的exports对象上添加name属性,值为'Webpack'
      var name = (exports.name = 'Webpack');
    },
    {}
  ]
});
⬅️ 分析文件的执行过程

1)整体大致分为 10 步,第一步从require(0)开始执行,调用内置的自定义 require 函数,跳转到第二步,执行fn函数

2)执行第三步require('./message.js'),继续跳转到第四步 require(mapping['./message.js']), 最终转化为require(1)

3)继续执行require(1),获取modules[1],也就是执行message.js的内容

4)第五步require('./name.js'),最终转化为require(2),执行name.js的内容

5)通过递归调用,将代码中导出的属性,放到exports对象中,一层层导出到最外层

6)最终通过_message2.default获取导出的值,页面显示hello Webpack!

Webpack 的打包流程

总结一下 webpack 完整的打包流程

1)webpack 从项目的entry入口文件开始递归分析,调用所有配置的 loader对模块进行编译

因为 webpack 默认只能识别 js 代码,所以如 css 文件、.vue 结尾的文件,必须要通过对应的 loader 解析成 js 代码后,webpack 才能识别

2)利用babel(babylon)将 js 代码转化为ast抽象语法树,然后通过babel-traverse对 ast 进行遍历

3)遍历的目的找到文件的import引用节点

因为现在我们引入文件都是通过 import 的方式引入,所以找到了 import 节点,就找到了文件的依赖关系

4)同时每个模块生成一个唯一的 id,并将解析过的模块缓存起来,如果其他地方也引入该模块,就无需重新解析,最后根据依赖关系生成依赖图谱

5)递归遍历所有依赖图谱的模块,组装成一个个包含多个模块的 Chunk(块)

6)最后将生成的文件输出到 output 的目录中

热更新原理

什么是 webpack 热更新?

开发过程中,代码发生变动后,webpack 会重新编译,编译后浏览器替换修改的模块,局部更新,无需刷新整个页面

好处:节省开发时间、提升开发体验

热更新原理

主要是通过websocket实现,建立本地服务和浏览器的双向通信。当代码变化,重新编译后,通知浏览器请求更新的模块,替换原有的模块

1) 通过webpack-dev-server开启server服务,本地 server 启动之后,再去启动 websocket 服务,建立本地服务和浏览器的双向通信

2) webpack 每次编译后,会生成一个Hash值,Hash 代表每一次编译的标识。本次输出的 Hash 值会编译新生成的文件标识,被作为下次热更新的标识

3)webpack监听文件变化(主要是通过文件的生成时间判断是否有变化),当文件变化后,重新编译

4)编译结束后,通知浏览器请求变化的资源,同时将新生成的 hash 值传给浏览器,用于下次热更新使用

5)浏览器拿到更新后的模块后,用新模块替换掉旧的模块,从而实现了局部刷新

轻松理解 webpack 热更新原理[2]
深入浅出 Webpack[3]
带你深度解锁 Webpack 系列(基础篇)[4]

Plugin

作用:扩展 webpack 功能

工作原理

webpack 通过内部的事件流机制保证了插件的有序性,底层是利用发布订阅模式,webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,在特定的时机对资源做处理

手写一个 Plugin 插件

// 自定义一个名为MyPlugin插件,该插件在打包完成后,在控制台输出"打包已完成"
class MyPlugin {
  // 原型上需要定义apply 的方法
  apply(compiler) {
    // 通过compiler获取webpack内部的钩子
    compiler.hooks.done.tap("My Plugin", (compilation, cb) => {
      console.log("打包已完成");
      // 分为同步和异步的钩子,异步钩子必须执行对应的回调
      cb();
    });
  }
}
module.exports = MyPlugin;

在 vue 项目中使用自定义插件

1)在vue.config.js引入该插件

const MyPlugin = require('./MyPlugin.js')

2)在configureWebpack的 plugins 列表中注册该插件

module.exports = {
  configureWebpack: {
    plugins: [new MyPlugin()]
  }
};

3)执行项目的打包命令
当项目打包成功后,会在控制台输出:打包已完成

Plugin 的组成部分

1)Plugin 的本质是一个 node 模块,这个模块导出一个 JavaScript 类

2)它的原型上需要定义一个apply 的方法

3)通过compiler获取 webpack 内部的钩子,获取 webpack 打包过程中的各个阶段

钩子分为同步和异步的钩子,异步钩子必须执行对应的回调

4)通过compilation操作 webpack 内部实例特定数据

5)功能完成后,执行 webpack 提供的 cb 回调

compiler 上暴露的一些常用的钩子简介

钩子 类型 调用时机
run AsyncSeriesHook 在编译器开始读取记录前执行
compile SyncHook 在一个新的 compilation 创建之前执行
compilation SyncHook 在一次 compilation 创建后执行插件
make AsyncParallelHook 完成一次编译之前执行
emit AsyncSeriesHook 在生成文件到 output 目录之前执行,回调参数: compilation
afterEmit AsyncSeriesHook 在生成文件到 output 目录之后执行
assetEmitted AsyncSeriesHook 生成文件的时候执行,提供访问产出文件信息的入口,回调参数:file,info
done AsyncSeriesHook 一次编译完成后执行,回调参数:stats

常用的 Plugin 插件

插件名称 作用
html-webpack-plugin 生成 html 文件,引入公共的 js 和 css 资源
webpack-bundle-analyzer 对打包后的文件进行分析,生成资源分析图
terser-webpack-plugin 代码压缩,移除 console.log 打印等
HappyPack Plugin 开启多线程打包,提升打包速度
Dllplugin 动态链接库,将项目中依赖的三方模块抽离出来,单独打包
DllReferencePlugin 配合 Dllplugin,通过 manifest.json 映射到相关的依赖上去
clean-webpack-plugin 清理上一次项目生成的文件
vue-skeleton-webpack-plugin vue 项目实现骨架屏

揭秘 webpack-plugin[5]

Loader

Loader 作用

webpack 只能直接处理 js 格式的资源,任何非 js 文件都必须被对应的loader处理转换为 js 代码

手写一个 loader

一个简单的style-loader

// 作用:将css内容,通过style标签插入到页面中
// source为要处理的css源文件
function loader(source) {
  let style = `
    let style = document.createElement('style');
    style.setAttribute("type", "text/css");
    style.innerHTML = ${source};
    document.head.appendChild(style)`;
  return style;
}
module.exports = loader;

在 vue 项目中使用自定义 loader

1)在vue.config.js引入该 loader

const MyStyleLoader = require('./style-loader')

2)在configureWebpack中添加配置

module.exports = {
  configureWebpack: {
    module: {
      rules: [
        {
          // 对main.css文件使用MyStyleLoader处理
          test: /main.css/,
          loader: MyStyleLoader
        }
      ]
    }
  }
};

3)项目重新编译
main.css样式已加载到页面中

loader 的组成部分

loader 的本质是一个 node模块,该模块导出一个函数,函数接收source(源文件),返回处理后的source

loader 执行顺序

相同优先级的 loader 链,执行顺序为:从右到左,从下到上

use: ['loader1', 'loader2', 'loader3'],执行顺序为 loader3 → loader2 → loader1

常用的 loader

名称 作用
style-loader 用于将 css 编译完成的样式,挂载到页面 style 标签上
css-loader 用于识别 .css 文件, 须配合 style-loader 共同使用
sass-loader/less-loader css 预处理器
postcss-loader 用于补充 css 样式各种浏览器内核前缀
url-loader 处理图片类型资源,可以转 base64
vue-loader 用于编译 .vue 文件
worker-loader 通过内联 loader 的方式使用 web worker 功能
style-resources-loader 全局引用对应的 css,避免页面再分别引入

揭秘 webpack-loader[6]

Webpack5 模块联邦

webpack5 模块联邦(Module Federation) 使 JavaScript 应用,得以从另一个 JavaScript 应用中动态的加载代码,实现共享依赖,用于前端的微服务化

比如项目A项目B,公用项目C组件,以往这种情况,可以将 C 组件发布到 npm 上,然后 A 和 B 再具体引入。当 C 组件发生变化后,需要重新发布到 npm 上,A 和 B 也需要重新下载安装

使用模块联邦后,可以在远程模块的 Webpack 配置中,将 C 组件模块暴露出去,项目 A 和项目 B 就可以远程进行依赖引用。当 C 组件发生变化后,A 和 B 无需重新引用

模块联邦利用 webpack5 内置的ModuleFederationPlugin插件,实现了项目中间相互引用的按需热插拔

Webpack ModuleFederationPlugin

重要参数说明

1)name 当前应用名称,需要全局唯一

2)remotes 可以将其他项目的  name 映射到当前项目中

3)exposes 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用

4)shared 是非常重要的参数,制定了这个参数,可以让远程加载的模块对应依赖,改为使用本地项目的依赖,如 React 或 ReactDOM

配置示例

new ModuleFederationPlugin({
     name: "app_1",
     library: { type: "var", name: "app_1" },
     filename: "remoteEntry.js",
     remotes: {
        app_02: 'app_02',
        app_03: 'app_03',
     },
     exposes: {
        antd: './src/antd',
        button: './src/button',
     },
     shared: ['react', 'react-dom'],
}),

精读《Webpack5 新特性 - 模块联邦》[7]
Webpack 5 模块联邦引发微前端的革命?[8]
Webpack 5 升级内容(二:模块联邦)[9]

Vite

Vite 被誉为下一代的构建工具

上手了几个项目后,果然名不虚传,热更新速度真的是快的飞起!

Vite 原理

1)Vite 利用浏览器支持原生的es module模块,开发时跳过打包的过程,提升编译效率

2)当通过 import 加载资源时,浏览器会发出 HTTP 请求对应的文件,Vite拦截到该请求,返回对应的模块文件

es module 简单示例

import { a } from './a.js'

1)当声明一个 script 标签类型为 module 时,浏览器将对其内部的 import 引用发起 HTTP 请求,获取模块内容

2)浏览器将发起一个对 HOST/a.js 的 HTTP 请求,获取到内容之后再执行

Vite 的限制

Vite 主要对应的场景是开发模式(生产模式是用 rollup 打包

Vite 热更新速度

Vite 热更新的速度不会随着模块增多而变慢

1)Webpack 的热更新原理:一旦某个依赖(比如上面的 a.js)改变,就将这个依赖所处的 整个module 更新,并将新的 module 发送给浏览器重新执行

试想如果依赖越来越多,就算只修改一个文件,热更新的速度会越来越慢

2)Vite 的热更新原理:如果 a.js 发生了改变,只会重新编译这个文件 a,而其余文件都无需重新编译

所以理论上 Vite 热更新的速度不会随着文件增加而变慢

手写 vite

推荐珠峰的从零手写 vite 视频[10]

vite 的实现流程

1)通过koa开启一个服务,获取请求的静态文件内容

2)通过es-module-lexer 解析 ast 拿到 import 的内容

3)判断 import 导入模块是否为三方模块,是的话,返回node_module下的模块, 如 import vue 返回 import './@modules/vue'

4)如果是.vue文件,vite 拦截对应的请求,读取.vue 文件内容进行编译,通过compileTemplate 编译模板,将template转化为render函数

5)通过 babel parse 对 js 进行编译,最终返回编译后的 js 文件

尤雨溪几年前开发的“玩具 vite”[11]
Vite 原理浅析[12]

Babel

AST 抽象语法树

这里先聊一下AST抽象语法树,因为ASTbabel的核心

什么是 AST ?

AST是源代码的抽象语法结构的树状表现形式

在 js 世界中,可以认为抽象语法树(AST)是最底层

一个简单的 AST 示例

let a = 1,转化成 AST 的结果

{
  "type": "Program",
  "start": 0,
  "end": 9,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 9,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 9,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "let"
    }
  ],
  "sourceType": "module"
}

AST 抽象语法树的结构,可以通过AST 网站[13]在线输入代码查看

AST 抽象语法树——最基础的 javascript 重点知识,99%的人根本不了解[14]

Babel 基本原理与作用

Babel 是一个 JS 编译器,把我们的代码转成浏览器可以运行的代码

作用

babel 主要用于将新版本的代码转换为向后兼容的 js 语法(Polyfill 方式),以便能够运行在各版本的浏览器或其他环境中

基本原理

核心就是 AST (抽象语法树)

首先将源码转成抽象语法树,然后对语法树进行处理生成新的语法树,最后将新语法树生成新的 JS 代码

Babel 的流程

3 个阶段: parsing (解析)、transforming (转换)、generating (生成)

1)通过babylon将 js 转化成 ast (抽象语法树)

2)通过babel-traverse是一个对 ast 进行遍历,使用 babel 插件转化成新的 ast

3)通过babel-generator将 ast 生成新的 js 代码

配置和使用

1)单个软件包在 .babelrc 中配置

.babelrc {
  // 预设: Babel 官方做了一些预设的插件集,称之为 Preset,我们只需要使用对应的 Preset 就可以了
  "presets": [],
   // babel和webpack类似,主要是通过plugin插件进行代码转化的,如果不配置插件,babel会将代码原样返回
  "plugins": []
}

2)vue 中,在 babel.config.js 中配置

配置 babel-plugin-component 插件,按需引入 elementUI

module.exports = {
   presets: ["@vue/app"],
    // 配置babel-plugin-component插件
   plugins: [
        [
   "component",
   {
     libraryName: "element-ui",
     styleLibraryName: "theme-chalk"
 }
     ]
  ]
};

3)配置browserslist

browserslist 用来控制要兼容浏览器版本,配置的范围越具体,就可以更精确控制Polyfill转化后的体积大小

"browserslist": [
   // 全球超过1%人使用的浏览器
   "> 1%",
   //  所有浏览器兼容到最后两个版本根据CanIUse.com追踪的版本
   "last 2 versions",
   // chrome 版本大于70
   "chrome >= 70"
   // 排除部分版本
   "not ie <= 8"
]

如何开发一个 babel 插件

Babel 插件的作用

Babel 插件担负着编译过程中的核心任务:转换 AST

babel 插件的基本格式

1)一个函数,参数是 babel,然后就是返回一个对象,key是visitor,然后里面的对象是一个箭头函数

2)函数有两个参数,path表示路径,state表示状态

3)CallExpression就是我们要访问的节点,path 参数表示当前节点的位置,包含的主要是当前节点(node)内容以及父节点(parent)内容

插件的简单格式示例

module.exports = function (babel) {
   let t = babel.type
   return {
      visitor: {
        CallExression: (path, state) => {
           do soming
     }}}}

一个最简单的插件: 将const a 转化为const b

创建 babelPluginAtoB.js

module.exports = function(babel) {
  let t = babel.types;
  return {
    visitor: {
      VariableDeclarator(path, state) {
        // VariableDeclarator 是要找的节点类型
        if (path.node.id.name == "a") {
        // path.node.id.name = 'b' 是不行的,想改变某个值,就是用对应的ast来替换,所以我们要把id是a的ast换成b的ast
          path.node.id = t.Identifier("b");
        }
      }
    }
  };
};

在.babelrc 中引入 babelPluginAtoB 插件

const babelPluginAtoB = require('./babelPluginAtoB.js');
{
    "plugins": [
        [babelPluginAtoB]
    ]
}

编写测试代码

let a = 1;
console.log(b);
// babel插件生效,没有报错,打印 1

Babel 入门教程[15]
Babel 中文文档[16]
不容错过的 Babel 知识[17]
快速写一个 babel 插件[18]

Gulp

gulp 是基于 node 流 实现的前端自动化开发的工具

适用场景

在前端开发工作中有很多“重复工作”,比如批量将Scss文件编译为CSS文件

这里主要聊一下,在开发的组件库中如何使用 gulp

Gulp 在组件库中的运用

elementUI为例,下载elementUI 源码[19]

打开packages/theme-chalk/gulpfile.js

该文件的作用是将 scss 文件编译为 css 文件

'use strict';

// 引入gulp
// series创建任务列表,
// src创建一个流,读取文件
// dest 创建一个对象写入到文件系统的流
const { series, src, dest } = require('gulp');
// gulp-dart-sass编译scss文件
const sass = require('gulp-dart-sass');
// gulp-autoprefixer 给css样式添加前缀
const autoprefixer = require('gulp-autoprefixer');
// gulp-cssmin 压缩css
const cssmin = require('gulp-cssmin');

// 处理src目录下的所有scss文件,转化为css文件
function compile() {
  return (
    src('./src/*.scss')
      .pipe(sass.sync().on('error', sass.logError))
      .pipe(
        // 给css样式添加前缀
        autoprefixer({
          overrideBrowserslist: ['ie > 9', 'last 2 versions'],
          cascade: false
        })
      )
      // 压缩css
      .pipe(cssmin())
      // 将编译好的css 输出到lib目录下
      .pipe(dest('./lib'))
  );
}

// 将src/fonts文件的字体文件 copy到 /lib/fonts目录下
function copyfont() {
  return src('./src/fonts/**')
    .pipe(cssmin())
    .pipe(dest('./lib/fonts'));
}

// series创建任务列表
exports.build = series(compile, copyfont);

Gulp 给 elementUI 增加一键换肤功能

总体流程

1)使用css var()定义颜色变量

2)创建主题theme.css文件,存储所有的颜色变量

3)使用gulptheme.css合并到base.css中,解决按需引入的情况

4)使用gulpindex.cssbase.css合并,解决全局引入的情况

步骤一:创建基础颜色变量theme.css文件

10w字!前端知识体系+大厂面试笔记(工程化篇)_第3张图片 theme.jpg

步骤二:修改packages/theme-chalk/src/common/var.scss文件

将该文件的中定义的 scss 变量,替换成 var()变量

10w字!前端知识体系+大厂面试笔记(工程化篇)_第4张图片 var.jpg

步骤三:修改后的packages/theme-chalk/gulpfile.js

'use strict';

const {series, src, dest} = require('gulp');
const sass = require('gulp-dart-sass');
const autoprefixer = require('gulp-autoprefixer');
const cssmin = require('gulp-cssmin');
const concat = require('gulp-concat');

function compile() {
  return src('./src/*.scss')
    .pipe(sass.sync().on('error', sass.logError))
    .pipe(autoprefixer({
      overrideBrowserslist: ['ie > 9', 'last 2 versions'],
      cascade: false
    }))
    .pipe(cssmin())
    .pipe(dest('./lib'));
}

// 将 theme.css 和 lib/base.css合并成 最终的 base.css
function compile1() {
  return src(['./src/theme.css', './lib/base.css'])
    .pipe(concat('base.css'))
    .pipe(dest('./lib'));
}

// 将 base.css、 index.css 合并成 最终的 index.css
function compile2() {
  return src(['./lib/base.css', './lib/index.css'])
    .pipe(concat('index.css'))
    .pipe(dest('./lib'));
}

function copyfont() {
  return src('./src/fonts/**')
    .pipe(cssmin())
    .pipe(dest('./lib/fonts'));
}

exports.build = series(compile, compile1, compile2, copyfont);

elementUI 多套主题下 按需引入和全局引入 的换肤方案[20]

脚手架

脚手架是开发中经常会使用的工具,比如vue-clicreate-react-app等,这些脚手架可以通过简单的命令,快速去搭建项目,让我们更专注于项目的开发

随着项目的增多、人员的扩展,大家开发的基础组件和公共方法也越来越多,希望把这些积累添加到脚手架中,当成项目模板留存下来

这样再创建项目时,就不用每次去其他项目中来回 copy

手写一个 mini 版的脚手架

下面我们一起,手写一个 mini 版的脚手架

通过这个案例来了解脚手架的工作流程,以及使用了哪些常用工具

新建文件夹my-build-cli,执行npm init -y

10w字!前端知识体系+大厂面试笔记(工程化篇)_第5张图片 init-y.png

配置脚手架入口文件

1)创建bin目录,该目录下创建www.js

bin/www.js 内容

#! /usr/bin/env node

console.log('link 成功');

注:/usr/bin/env node 这行的意思是使用 node 来执行此文件

2)package.json中配置入口文件的路径

{
  "name": "my-build-cli",
  "version": "1.0.0",
  "description": "",
  "bin": "./bin/www.js", // 手动添加入口文件为 ./bin/www.js
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

3)项目目录结构

my-build-cli
├─ bin
│  └─ www.js
└─ package.json

npm link 到全局

在控制台输入npm link

10w字!前端知识体系+大厂面试笔记(工程化篇)_第6张图片 link.jpg

测试是否连接成功

在控制台输入my-build-cli

10w字!前端知识体系+大厂面试笔记(工程化篇)_第7张图片 linksuccess.jpg

在控制台输出link 成功, 项目配置成功

安装脚手架所需的工具

一次性安装所需的工具

npm install commander inquirer download-git-repo util ora fs-extra axios

工具名称 作用
commander 自定义命令行工具
inquirer 命令行交互工具
download-git-repo 从 git 上下载项目模板工具
util download-git-repo 不支持异步调用,需要使用 util 插件的util.promisify进行转换
ora 命令行 loading 动效
fs-extra 提供文件操作方法
axios 发送接口,请求 git 上的模板列表

commander 自定义命令行工具

commander.js 是自定义命令行工具

这里用来创建create 命令,用户可以通过输入 my-cli creat appName 来创建项目

修改www.js

#! /usr/bin/env node

const program = require('commander');

program
  // 创建create 命令,用户可以通过 my-cli creat appName 来创建项目
  .command('create ')
  // 命名的描述
  .description('create a new project')
  // create命令的选项
  .option('-f, --force', 'overwrite target if it exist')
  .action((name, options) => {
    // 执行'./create.js',传入项目名称和 用户选项
    require('./create')(name, options);
  });

program.parse();

inquirer 命令行交互工具

inquirer.js 命令行交互工具,用来询问用户的操作,让用户输入指定的信息,或给出对应的选项让用户选择

此处 inquirer 的运用场景有 2 个

1)场景 1:当用户要创建的项目目录已存在时,提示用户是否要覆盖 or 取消

2)场景 2:让用户输入项目的author作者和项目description描述

创建create.js

bin/create.js

const path = require('path');
const fs = require('fs-extra');
const inquirer = require('inquirer');
const Generator = require('./generator');

module.exports = async function (name, options) {
  // process.cwd获取当前的工作目录
  const cwd = process.cwd();
  // path.join拼接 要创建项目的目录
  const targetAir = path.join(cwd, name);

  // 如果该目录已存在
  if (fs.existsSync(targetAir)) {
    // 强制删除
    if (options.force) {
      await fs.remove(targetAir);
    } else {
      // 通过inquirer:询问用户是否确定要覆盖 or 取消
      let { action } = await inquirer.prompt([
        {
          name: 'action',
          type: 'list',
          message: 'Target already exists',
          choices: [
            {
              name: 'overwrite',
              value: 'overwrite'
            },
            {
              name: 'cancel',
              value: false
            }
          ]
        }
      ]);
      if (!action) {
        return;
      } else {
        // 删除文件夹
        await fs.remove(targetAir);
      }
    }
  }

  const args = require('./ask');

  // 通过inquirer,让用户输入的项目内容:作者和描述
  const ask = await inquirer.prompt(args);
  // 创建项目
  const generator = new Generator(name, targetAir, ask);
  generator.create();
};

创建ask.js

配置 ask 选项,让用户输入作者和项目描述

bin/create.js

// 配置ask 选项
module.exports = [
  {
    type: 'input',
    name: 'author',
    message: 'author?'
  },
  {
    type: 'input',
    name: 'description',
    message: 'description?'
  }
];

创建generator.js

generator.js的工作流程

1)通过接口获取git上的模板目录

2)通过inquirer让用户选择需要下载的项目

3)使用download-git-repo下载用户选择的项目模板

4)将用户创建时,将项目名称、作者名字、描述写入到项目模板的package.json文件中

bin/generator.js

const path = require('path');
const fs = require('fs-extra');
// 引入ora工具:命令行loading 动效
const ora = require('ora');
const inquirer = require('inquirer');
// 引入download-git-repo工具
const downloadGitRepo = require('download-git-repo');
// download-git-repo 默认不支持异步调用,需要使用util插件的util.promisify 进行转换
const util = require('util');
// 获取git项目列表
const { getRepolist } = require('./http');

async function wrapLoading(fn, message, ...args) {
  const spinner = ora(message);
  // 下载开始
  spinner.start();

  try {
    const result = await fn(...args);
    // 下载成功
    spinner.succeed();
    return result;
  } catch (e) {
    // 下载失败
    spinner.fail('Request failed ……');
  }
}

// 创建项目类
class Generator {
  // name 项目名称
  // target 创建项目的路径
  // 用户输入的 作者和项目描述 信息
  constructor(name, target, ask) {
    this.name = name;
    this.target = target;
    this.ask = ask;
    // download-git-repo 默认不支持异步调用,需要使用util插件的util.promisify 进行转换
    this.downloadGitRepo = util.promisify(downloadGitRepo);
  }
  async getRepo() {
    // 获取git仓库的项目列表
    const repolist = await wrapLoading(getRepolist, 'waiting fetch template');
    if (!repolist) return;

    const repos = repolist.map((item) => item.name);

    // 通过inquirer 让用户选择要下载的项目模板
    const { repo } = await inquirer.prompt({
      name: 'repo',
      type: 'list',
      choices: repos,
      message: 'Please choose a template'
    });

    return repo;
  }

  // 下载用户选择的项目模板
  async download(repo, tag) {
    const requestUrl = `yuan-cli/${repo}`;
    await wrapLoading(this.downloadGitRepo, 'waiting download template', requestUrl, path.resolve(process.cwd(), this.target));
  }

  // 文件入口,在create.js中 执行generator.create();
  async create() {
    const repo = await this.getRepo();
    console.log('用户选择了', repo);

    // 下载用户选择的项目模板
    await this.download(repo);

    // 下载完成后,获取项目里的package.json
    // 将用户创建项目的填写的信息(项目名称、作者名字、描述),写入到package.json中
    let targetPath = path.resolve(process.cwd(), this.target);

    let jsonPath = path.join(targetPath, 'package.json');

    if (fs.existsSync(jsonPath)) {
      // 读取已下载模板中package.json的内容
      const data = fs.readFileSync(jsonPath).toString();
      let json = JSON.parse(data);
      json.name = this.name;
      // 让用户输入的内容 替换到 package.json中对应的字段
      Object.keys(this.ask).forEach((item) => {
        json[item] = this.ask[item];
      });

      //修改项目文件夹中 package.json 文件
      fs.writeFileSync(jsonPath, JSON.stringify(json, null, '\t'), 'utf-8');
    }
  }
}

module.exports = Generator;

创建http.js

用来发送接口,获取 git 上的模板列表

bin/http.js

// 引入axios
const axios = require('axios');

axios.interceptors.response.use((res) => {
  return res.data;
});

// 获取git上的项目列表
async function getRepolist() {
  return axios.get('https://api.github.com/orgs/yuan-cli/repos');
}

module.exports = {
  getRepolist
};

最终的目录结构

10w字!前端知识体系+大厂面试笔记(工程化篇)_第8张图片 list.jpg

脚手架效果演示

text8.gif

脚手架发布到 npm 库

完善 package.json

1)配置main属性,指定包的入口 "main": "./bin/www.js"

2)增加files属性,files 用来描述当把 npm 包,作为依赖包安装的文件列表。当 npm 包发布时,files 指定的文件会被推送到 npm 服务器中

3)增加descriptionkeywords等描述字段

{
  "name": "my-2022-cli",
  "version": "1.1.0",
  "description": "一个mini版的脚手架",
  "main": "./bin/www.js",
  "bin": "./bin/www.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "files": [
    "bin"
  ],
  "keywords": [
    "my-yuan-cli",
    "自定义脚手架"
  ],
  "author": "海阔天空",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.24.0",
    "commander": "^8.3.0",
    "download-git-repo": "^3.0.2",
    "fs-extra": "^10.0.0",
    "inquirer": "^8.2.0",
    "ora": "^5.4.1",
    "util": "^0.12.4"
  }
}

增加 README.md 说明文档

## my-2022-cli

一个 mini 版的自定义脚手架

### 安装

npm install my-2022-cli -g

### 使用说明

1)通过 my-2022-cli create appName 创建项目

2)author? 输入项目作者

3)description? 输入项目描述

4)选择项目模块 appDemo or pcDemo

5)安装选择的模板

### 演示示例

![Image text](https://wx1.sinaimg.cn/mw2000/927e36bfgy1h69k6ee9z1g20rs0jfwit.gif)

发布成功后,在 npm 网站搜索my-2022-cli

10w字!前端知识体系+大厂面试笔记(工程化篇)_第9张图片 my-2022-cli.jpg

自定义脚手架 github 源码地址[21]
my-2022-cli[22]

性能分析与优化

一百用户与一百万用户的网站有着本质区别

随着用户的增长,任何细节的优化都变得更为重要,网站的性能差异直接影响着用户的体验

试想,如果我们在网上购物,商城页面好几秒才打开,或图片加载不出来,购物的欲望瞬间消减,抬手就去其他竞品平台了

我曾经负责过几个大型项目的整体性能优化,尽量从实战的角度聊一聊自己所理解的性能问题

性能分析工具

好比去医院看病一样,得了什么病,通过检测化验后才知道。网站也是一样,需要借助性能分析工具来检测

Lighthouse 工具

Lighthouse是 Chrome 自带的性能分析工具,它能够生成一个有关页面性能的报告

通过报告我们可以知道需要采取哪些措施,来改进应用的性能和体验

并且 Lighthouse 可以对页面多方面的效果指标进行评测,并给出最佳实践的建议,以帮助开发者改进网站的质量

Lighthouse 拿到页面的“病情”报告

通过 Lighthouse 拿到网站的整体分析报告,通过报告来诊断“病情”

这里以https://juejin.cn[23]网站为例, 打开 Chrome 浏览器控制台,选择Lighthouse选项,点击Generate report

10w字!前端知识体系+大厂面试笔记(工程化篇)_第10张图片 Lighthouse.jpg

Lighthouse 能够生成一份该网站的报告,比如下图:

10w字!前端知识体系+大厂面试笔记(工程化篇)_第11张图片 performance.jpg

这里重点关注Performance性能评分

性能评分的分值区间是 0 到 100,如果出现 0 分,通常是在运行 Lighthouse 时发生了错误,满分 100 分代表了网站已经达到了 98 分位值的数据,而 50 分则对应 75 分位值的数据

小伙伴看看自己开发的项目得分是多少,处于什么样的水平

Lighthouse 给出 Opportunities 优化建议

Lighthouse 会针对当前网站,给出一些Opportunities优化建议

Opportunities 指的是优化机会,它提供了详细的建议和文档,来解释低分的原因,帮助我们具体进行实现和改进

10w字!前端知识体系+大厂面试笔记(工程化篇)_第12张图片 opportunity.jpg

举一个我曾开发过的一个项目,以下是

Opportunities 给出优化建议列表

问题 建议
Remove unused JavaScript 去掉无用 js 代码
Preload key requests 首页资源 preload 预加载
Remove unused CSS 去掉无用 css 代码
Serve images in next-gen formats 使用新的图片格式,比如 webp 相对 png jpg 格式体积更小
Efficiently encode images 比如压缩图片大小
Preconnect to required origins 使用 preconnect or dns-prefetch DNS 预解析

Lighthouse 给出 Diagnostics 诊断问题列表

Diagnostics 指的是现在存在的问题,为进一步改善性能的验证和调整给出了指导

10w字!前端知识体系+大厂面试笔记(工程化篇)_第13张图片 DiagNo.jpg

Diagnostics 诊断问题列表

问题 影响
A long cache lifetime can speed up repeat visits to your page 这些资源需要提供长的缓存期,现发现图片都是用的协商缓存,显然不合理
Image elements do not have explicit width and height 给图片设置具体的宽高,减少 cls 的值
Avoid enormous network payloads 资源太大增加网络负载
Minimize main-thread work 最小化主线程 这里会执行解析 Html、样式计算、布局、绘制、合成等动作
Reduce JavaScript execution time 减少非必要 js 资源的加载,减少必要 js 资源的大小
Avoid large layout shifts 避免大的布局变化,从中可以看到影响布局变化最大的元素

这些Opportunities建议和Diagnostics诊断问题是非常具体且有效的(亲测),开发者可以根据这些建议,一条条去修改或优化

Lighthouse 列出 Performance 各指标得分

Performance 列出了FCP、SP、LCP、TTI、TBI、CLS 六个指标的用时和得分情况

下文会聊一聊这些指标的用法与作用

10w字!前端知识体系+大厂面试笔记(工程化篇)_第14张图片 performance1.jpg

性能测评工具 lighthouse 的使用[24]

Web-vitals 官方标准

web-vitals[25]是 Google 给出的定义是 一个良好网站的基本指标

过去要衡量一个网站的好坏,需要使用的指标太多了,现在我们可以将重点聚焦于 Web Vitals 指标的表现即可

官方指标标准

指标 作用 标准
FCP(First Contentful Paint) 首次内容绘制时间 标准 ≤1s
LCP(Largest Contentful Paint) 最大内容绘制时间 标准 ≤2 秒
FID(first input delay) 首次输入延迟,标准是用户触发后,到浏览器响应时间 标准 ≤100ms
CLS(Cumulative Layout Shift) 累积布局偏移 标准 ≤0.1
TTFB(Time to First Byte) 页面发出请求,到接收第一个字节所花费的毫秒数(首字节时间) 标准<= 100 毫秒

我们将 Lighthouse 中 Performance 列出的指标表现,与官方指标标准做对比,可以发现页面哪些指标超出了范围

Performance 工具

通过 Lighthouse 我们知道了页面整体的性能得分,但是页面打开慢或者卡顿的瓶颈在哪里?

具体是加载资源慢dom渲染慢、还是js执行慢呢?

chrome 浏览器提供的performance是常用来查看网页性能的工具,通过该工具,我们可以知道页面在浏览器运行时的性能表现

Performance 寻找性能瓶颈

打开 Chrome 浏览器控制台,选择Performance选项,点击左侧reload图标

10w字!前端知识体系+大厂面试笔记(工程化篇)_第15张图片 perfromance1.gif

Performance 面板可以记录和分析页面在运行时的所有活动,大致分为以下 4 个区域

10w字!前端知识体系+大厂面试笔记(工程化篇)_第16张图片 performance2.png

Performance 各区域功能介绍

1)FPS

FPS(Frames Per Second),表示每秒传输帧数,是用来分析页面是否卡顿的一个主要性能指标

如下图所示,绿色的长条越高,说明FPS越高,用户体验越好

如果发现了一个红色的长条,那么就说明这些帧存在严重问题,可能会造成页面卡顿

8f5020b833bfecf72a66966f5a795c71.png FPS.png

2)NET

NET 记录资源的等待、下载、执行时间,每条彩色横杠表示一种资源

横杠越长,检索资源所需的时间越长。每个横杠的浅色部分表示等待时间(从请求资源到第一个字节下载完成的时间)

Network 的颜色说明:白色表示等待的颜色、浅黄色表示请求的时间、深黄色表示下载的时间

在这里,我们可以看到所有资源的加载过程,有两个地方重点关注:

1)资源等待的时间是否过长(标准 ≤100ms)

2)资源文件体积是否过大,造成加载很慢(就要考虑如何拆分该资源)

7049f4eaffb9463c494313627bd23f15.png net.png

3)火焰图

火焰图(Flame Chart)用来可视化 CPU 堆栈信息记录

10w字!前端知识体系+大厂面试笔记(工程化篇)_第17张图片1)Network: 表示加载了哪些资源
2)Frames:表示每幅帧的运行情况
3)Timings: 记录页面中关键指标的时间
4)Main:表示主线程(重点,下文会详细介绍)
5)GPU:表示 GPU 占用情况

4)统计汇总

Summary: 表示各指标时间占用统计报表

1)Loading: 加载时间
2)Scripting: js计算时间
3)Rendering: 渲染时间
4)Painting: 绘制时间
5)Other: 其他时间
6)Idle: 浏览器闲置时间
10w字!前端知识体系+大厂面试笔记(工程化篇)_第18张图片 sum.jpg

Performance Main 性能瓶颈的突破口

Main 表示主线程,主要负责

1)Javascript 的计算与执行
2)CSS 样式计算
3)Layout 布局计算
4)将页面元素绘制成位图(paint),也就是光栅化(Raster)

展开 Main,可以发现很多红色三角(long task),这些执行时间超过 50ms就属于长任务,会造成页面卡顿,严重时会造成页面卡死

10w字!前端知识体系+大厂面试笔记(工程化篇)_第19张图片 main.jpg

展开其中一个红色三角,Devtools 在Summary面板里展示了更多关于这个事件的信息

10w字!前端知识体系+大厂面试笔记(工程化篇)_第20张图片 app.jpg

在 summary 面板里点击app.js链接,Devtools 可以跳转到需要优化的代码处

10w字!前端知识体系+大厂面试笔记(工程化篇)_第21张图片 source.jpg

下面我们需要结合自己的代码逻辑,去判断这块代码为什么执行时间超长?

如何去解决或优化这些long task,从而解决去页面的性能瓶颈

全新 Chrome Devtool Performance 使用指南[26]
手把手带你入门前端工程化——超详细教程[27]
Chrome Devtool — Performance[28]

性能监控

项目发布生产后,用户使用时的性能如何,页面整体的打开速度是多少、白屏时间多少,FP、FCP、LCP、FID、CLS 等指标,要设置多大的阀值呢,才能满足TP50、TP90、TP99的要求呢?

TP 指标: 总次数 * 指标数 = 对应 TP 指标的值。

设置每个指标的阀值,比如 FP 指标,设置阀值为 1s,要求 Tp95,即 95%的 FP 指标,要在 1s 以下,剩余 5%的指标超过 1s

TP50 相对较低,TP90 则比较高,TP99,TP999 则对性能要求很高

这里就需要性能监控,采集到用户的页面数据

性能指标的计算

常用的两种方式:

方式一:通过 web-vitals 官方库进行计算

import {onLCP, onFID, onCLS} from 'web-vitals';

onCLS(console.log);
onFID(console.log);
onLCP(console.log);

方式二:通过performance api进行计算

下面聊一下 performance api 来计算各种指标

打开任意网页,在控制台中输入 performance 回车,可以看到一系列的参数,

performance.timing

重点看下performance.timing,记录了页面的各个关键时间点

时间 作用
navigationStart (可以理解为该页面的起始时间)同一个浏览器上下文的上一个文档卸载结束时的时间戳,如果没有上一个文档,这个值会和 fetchStart 相同
unloadEventStart unload 事件抛出时的时间戳,如果没有上一个文档,这个值会是 0
unloadEventEnd unload 事件处理完成的时间戳,如果没有上一个文档,这个值会是 0
redirectStart 第一个 HTTP 重定向开始时的时间戳,没有重定向或者重定向中的不同源,这个值会是 0
redirectEnd 最后一个 HTTP 重定向开始时的时间戳,没有重定向或者重定向中的不同源,这个值会是 0
fetchStart 浏览器准备好使用 HTTP 请求来获取文档的时间戳。发送在检查缓存之前
domainLookupStart 域名查询开始的时间戳,如果使用了持续连接或者缓存,则与 fetchStart 一致
domainLookupEnd 域名查询结束的时间戳,如果使用了持续连接或者缓存,则与 fetchStart 一致
connectStart HTTP 请求开始向服务器发送时的时间戳,如果使用了持续连接,则与 fetchStart 一致
connectEnd 浏览器与服务器之间连接建立(所有握手和认证过程全部结束)的时间戳,如果使用了持续连接,则与 fetchStart 一致
secureConnectionStart 浏览器与服务器开始安全连接握手时的时间戳,如果当前网页不需要安全连接,这个值会是 0
requestStart 浏览器向服务器发出 HTTP 请求的时间戳
responseStart 浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳
responseEnd 浏览器从服务器收到(或从本地缓存读取)最后一个字节时(如果在此之前 HTTP 连接已经关闭,则返回关闭时)的时间戳
domLoading 当前网页 DOM 结构开始解析时的时间戳
domInteractive 当前网页 DOM 结构解析完成,开始加载内嵌资源时的时间戳
domContentLoadedEventStart 需要被执行的脚本已经被解析的时间戳
domContentLoadedEventEnd 需要立即执行的脚本已经被执行的时间戳
domComplete 当前文档解析完成的时间戳
loadEventStart load 事件被发送时的时间戳,如果这个事件还未被发送,它的值将会是 0
loadEventEnd load 事件结束时的时间戳,如果这个事件还未被发送,它的值将会是 0

白屏时间 FP

白屏时间 FP(First Paint)指的是从用户输入 url 的时刻开始计算,一直到页面有内容展示出来的时间节点,标准≤2s

这个过程包括 dns 查询、建立 tcp 连接、发送 http 请求、返回 html 文档、html 文档解析

const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-paint') {
            observer.disconnect()}
       // 其中startTime 就是白屏时间
       let FP = entry.startTime)
    }
}
const observer = new PerformanceObserver(entryHandler)
// buffered 属性表示是否观察缓存数据,也就是说观察代码添加时机比事件触发时机晚也没关系。
observer.observe({ type: 'paint', buffered: true })

首次内容绘制时间 FCP

FCP(First Contentful Paint) 表示页面任一部分渲染完成的时间,标准≤1s

// 计算方式:
const entryHandler = (list) => {
    for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
            observer.disconnect()
        }
        // 计算首次内容绘制时间
       let FCP = entry.startTime
    }
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })

最大内容绘制时间 LCP

LCP(Largest Contentful Paint)表示最大内容绘制时间,标准≤2 秒

// 计算方式:
const entryHandler = (list) => {
    if (observer) {
        observer.disconnect()
    }
    for (const entry of list.getEntries()) {
        // 最大内容绘制时间
       let LCP = entry.startTime
    }
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'largest-contentful-paint', buffered: true })

累积布局偏移值 CLS

CLS(Cumulative Layout Shift) 表示累积布局偏移,标准≤0.1

// cls为累积布局偏移值
let cls = 0;
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value;
    }
  }
}).observe({type: 'layout-shift', buffered: true});

首字节时间 TTFB

平常所说的TTFB,默认指导航请求的TTFB

导航请求:在浏览器切换页面时创建,从导航开始到该请求返回 HTML

window.onload = function () {
  // 首字节时间
  let TTFB = responseStart - navigationStart;
};

首次输入延迟 FID

FID(first input delay)首次输入延迟,标准是用户触发后,浏览器的响应时间, 标准≤100ms

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // 计算首次输入延迟时间
    const FID = entry.processingStart - entry.startTime;
  }
}).observe({ type: 'first-input', buffered: true });

FID 推荐使用 web-vitals 库,因为官方兼容了很多场景

首页加载时间

window.onload = function () {
  // 首页加载时间
  // domComplete 是document的readyState = complete(完成)的状态
  let firstScreenTime = performance.timing.domComplete - performance.timing.navigationStart;
};

首屏加载时间

首屏加载时间和首页加载时间不一样,首屏指的是用户看到屏幕内页面渲染完成的时间

比如首页很长需要好几屏展示,这种情况下屏幕以外的元素不考虑在内

计算首屏加载时间流程

1)利用MutationObserver监听document对象,每当 dom 变化时触发该事件

2)判断监听的 dom 是否在首屏内,如果在首屏内,将该 dom 放到指定的数组中,记录下当前 dom 变化的时间点

3)在 MutationObserver 的 callback 函数中,通过防抖函数,监听document.readyState状态的变化

4)当document.readyState === 'complete',停止定时器和 取消对 document 的监听

5)遍历存放 dom 的数组,找出最后变化节点的时间,用该时间点减去performance.timing.navigationStart 得出首屏的加载时间

定义 performance.js

// firstScreenPaint为首屏加载时间的变量
let firstScreenPaint = 0;
// 页面是否渲染完成
let isOnLoaded = false;
let timer;
let observer;

// 定时器循环监听dom的变化,当document.readyState === 'complete'时,停止监听
function checkDOMChange(callback) {
  cancelAnimationFrame(timer);
  timer = requestAnimationFrame(() => {
    if (document.readyState === 'complete') {
      isOnLoaded = true;
    }
    if (isOnLoaded) {
      // 取消监听
      observer && observer.disconnect();

      // document.readyState === 'complete'时,计算首屏渲染时间
      firstScreenPaint = getRenderTime();
      entries = null;

      // 执行用户传入的callback函数
      callback && callback(firstScreenPaint);
    } else {
      checkDOMChange();
    }
  });
}
function getRenderTime() {
  let startTime = 0;
  entries.forEach((entry) => {
    if (entry.startTime > startTime) {
      startTime = entry.startTime;
    }
  });
  // performance.timing.navigationStart 页面的起始时间
  return startTime - performance.timing.navigationStart;
}
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// dom 对象是否在屏幕内
function isInScreen(dom) {
  const rectInfo = dom.getBoundingClientRect();
  if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {
    return true;
  }
  return false;
}
let entries = [];

// 外部通过callback 拿到首屏加载时间
export default function observeFirstScreenPaint(callback) {
  const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK'];
  observer = new window.MutationObserver((mutationList) => {
    checkDOMChange(callback);
    const entry = { children: [] };
    for (const mutation of mutationList) {
      if (mutation.addedNodes.length && isInScreen(mutation.target)) {
        for (const node of mutation.addedNodes) {
          // 忽略掉以上标签的变化
          if (node.nodeType === 1 && !ignoreDOMList.includes(node.tagName) && isInScreen(node)) {
            entry.children.push(node);
          }
        }
      }
    }

    if (entry.children.length) {
      entries.push(entry);
      entry.startTime = new Date().getTime();
    }
  });
  observer.observe(document, {
    childList: true, // 监听添加或删除子节点
    subtree: true, // 监听整个子树
    characterData: true, // 监听元素的文本是否变化
    attributes: true // 监听元素的属性是否变化
  });
}

外部引入使用

import observeFirstScreenPaint from './performance';

// 通过回调函数,拿到首屏加载时间
observeFirstScreenPaint((data) => {
  console.log(data, '首屏加载时间');
});

DOM 渲染时间和 window.onload 时间

DOM 的渲染的时间和 window.onload 执行的时间不是一回事

DOM 渲染的时间

DOM渲染的时间 =  performance.timing.domComplete - performance.timing.domLoading

window.onload 要晚于 DOM 的渲染,window.onload 是页面中所有的资源都加载后才执行(包括图片的加载)

window.onload 的时间

window.onload的时间 =  performance.timing.loadEventEnd

计算资源的缓存命中率

缓存命中率:从缓存中得到数据的请求数与所有请求数的比率

理想状态是缓存命中率越高越好,缓存命中率越高说明网站的缓存策略越有效,用户打开页面的速度也会相应提高

如何判断该资源是否命中缓存?

1)通过performance.getEntries()找到所有资源的信息

2)在这些资源对象中有一个transferSize 字段,它表示获取资源的大小,包括响应头字段和响应数据的大小

3)如果这个值为 0,说明是从缓存中直接读取的(强制缓存)

4)如果这个值不为 0,但是encodedBodySize 字段为 0,说明它走的是协商缓存(encodedBodySize 表示请求响应数据 body 的大小

function isCache(entry) {
  // 直接从缓存读取或 304
  return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0);
}

所有命中缓存的数据 / 总数据 就能得出缓存命中率

性能数据上报

上报方式

一般使用图片打点的方式,通过动态创建 img 标签的方式,new 出像素为1x1 pxgif Image(gif 体积最小)对象就能发起请求,可以跨域、不需要等待服务器返回数据

上报时机

可以利用requestIdleCallback,浏览器空闲的时候上报,好处是:不阻塞其他流程的进行

如果浏览器不支持该requestIdleCallback,就使用setTimeout上报

// 优先使用requestIdleCallback
if (window.requestIdleCallback) {
  window.requestIdleCallback(
    () => {
      // 获取浏览器的剩余空闲时间
      console.log(deadline.timeRemaining());
      report(data); // 上报数据
    },
    // timeout设置为1000,如果在1000ms内没有执行该后调,在下次空闲时间时,callback会强制执行
    { timeout: 1000 }
  );
} else {
  setTimeout(() => {
    report(data); // 上报数据
  });
}

内存分析与优化

性能分析很火,但内存分析相比就低调很多了

举一个我之前遇到的情况,客户电脑配置低,打开公司开发的页面,经常出现页面崩溃

调查原因,就是因为页面内存占用太大,客户打开几个页面后,内存直接拉满,也是经过这件事,我开始重视内存分析与优化

下面,聊一聊内存这块有哪些知识

Memory 工具

Memory 工具,通过内存快照的方式,分析当前页面的内存使用情况

Memory 工具使用流程

1)打开 chrome 浏览器控制台,选择Memory工具

2)点击左侧start按钮,刷新页面,开始录制的JS堆动态分配时间线,会生成页面加载过程内存变化的柱状统计图(蓝色表示未回收,灰色表示已回收)

10w字!前端知识体系+大厂面试笔记(工程化篇)_第22张图片 memory.jpg

Memory 工具中的关键项

关键项

Constructor:对象的类名;
Distance:对象到根的引用层级;
Objects Count:对象的数量;
Shallow Size: 对象本身占用的内存,不包括引用的对象所占内存;
Retained Size: 对象所占总内存,包含引用的其他对象所占内存;
Retainers:对象的引用层级关系

通过一段测试代码来了解 Memory 工具各关键性的关系

// 测试代码
class Jane {}
class Tom {
  constructor () { this.jane = new Jane();}
}
Array(1000000).fill('').map(() => new Tom())
10w字!前端知识体系+大厂面试笔记(工程化篇)_第23张图片 cpudemo.jpg

shallow size 和 retained size 的区别,以用红框里的 Tom 和 Jane 更直观的展示

Tom 的 shallow 占了 32M,retained 占用了 56M,这是因为 retained 包括了引用的指针对应的内存大小,即 tom.jane 所占用的内存

所以 Tom 的 retained 总和比 shallow 多出来的 24M,正好跟 Jane 占用的 24M 相同

retained size 可以理解为当回收掉该对象时可以释放的内存大小,在内存调优中具有重要参考意义

内存分析的关键点

找到内存最高的节点,分析这些时刻执行了哪些代码,发生了什么操作,尽可能去优化它们

1)从柱状图中找到最高的点,重点分析该时间内造成内存变大的原因

2)按照Retainers size(总内存大小)排序,点击展开内存最高的哪一项,点击展开构造函数,可以看到所有构造函数相关的对象实例

3)选中构造函数,底部会显示对应源码文件,点击源码文件,可以跳转到具体的代码,这样我们就知道是哪里的代码造成内存过大

4)结合具体的代码逻辑,来判断这块内存变大的原因,重点是如何去优化它们,降低内存的使用大小

10w字!前端知识体系+大厂面试笔记(工程化篇)_第24张图片 retainedSize.jpg

点击keyghost.js可以跳转到具体的源码

10w字!前端知识体系+大厂面试笔记(工程化篇)_第25张图片 localkey.png

内存泄露的情况

1)意外的全局变量, 挂载到 window 上全局变量
2)遗忘的定时器,定时器没有清除
3)闭包不当的使用

内存分析总结

1)利用 Memory 工具,了解页面整体的内存使用情况

2)通过 JS 堆动态分配时间线,找到内存最高的时刻

3)按照 Retainers size(总内存大小)排序,点击展开内存最高的前几项,分析由于哪个函数操作导致了内存过大,甚至是内存泄露

4)结合具体的代码,去解决或优化内存变大的情况

chrome 内存泄露(一)、内存泄漏分析工具[29]
chrome 内存泄露(二)、内存泄漏实例[30]
JavaScript 进阶-常见内存泄露及如何避免[31]

项目优化总结

优化的本质:响应更快,展示更快

更详细的说,是指在用户输入 url,到页面完整展示出来的过程中,通过各种优化策略和方法,让页面加载更快;在用户使用过程中,让用户的操作响应更及时,有更好的用户体验

经典:雅虎军规

很多前端优化准则都是围绕着这个展开

10w字!前端知识体系+大厂面试笔记(工程化篇)_第26张图片 雅虎35条军规.jpg

优化建议

结合我曾经负责优化的项目实践,在下面总结了一些经验与方法,提供给大家参考

1、分析打包后的文件

可以使用webpack-bundle-analyzer[32]插件(vue 项目可以使用--report)生成资源分析图

我们要清楚的知道项目中使用了哪些三方依赖,以及依赖的作用。特别对于体积大的依赖,分析是否能优化

比如:组件库如elementUI的按需引入、Swiper轮播图组件打包后的体积约 200k,看是否能替换成体积更小的插件、momentjs去掉无用的语言包等

10w字!前端知识体系+大厂面试笔记(工程化篇)_第27张图片 vendors.png

2、合理处理公共资源

如果项目支持 CDN,可以配置externals,将Vue、Vue-router、Vuex、echarts等公共资源,通过 CDN 的方式引入,不打到项目里边

如果项目不支持 CDN,可以使用DllPlugin动态链接库,将业务代码和公共资源代码相分离,公共资源单独打包,给这些公共资源设置强缓存(公共资源基本不会变),这样以后可以只打包业务代码,提升打包速度

3、首屏必要资源 preload 预加载 和 DNS 预解析

preload 预加载


preload 预加载是告诉浏览器页面必定需要的资源,浏览器会优先加载这些资源;使用 link 标签创建(vue 项目打包后,会将首页所用到的资源都加上 preload)

注意:preload 只是预加载资源,但不会执行,还需要引入具体的文件后才会执行 

你可能感兴趣的:(scipy,ipad,relativelayout,sharepoint,annotations)