前端森林

前端森林 查看完整档案

上海编辑  |  填写毕业院校  |  填写所在公司/组织 github.com/Cosen95 编辑
编辑

个人动态

前端森林 发布了文章 · 1月19日

哔哩哔哩面试官:你可以手写Vue2的响应式原理吗?

写在前面

这道题目是面试中相当高频的一道题目了,但凡你简历上有写:“熟练使用Vue并阅读过其部分源码”,那么这道题目十有八九面试官都会去问你。

什么?你简历上不写阅读过源码,那面试官也很有可能会问你是否阅读过响应式相关的源码

还是那句歌词唱的:

挣不脱 逃不过
眉头解不开的结
命中解不开的劫

整体流程

作为一个前端的MVVM框架,Vue的基本思路和AngularReact并无二致,其核心就在于: 当数据变化时,自动去刷新页面DOM,这使得我们能从繁琐的DOM操作中解放出来,从而专心地去处理业务逻辑。

这就是Vue的数据双向绑定(又称响应式原理)。数据双向绑定是Vue最独特的特性之一。此处我们用官方的一张流程图来简要地说明一下Vue响应式系统的整个流程:

Vue中,每个组件实例都有相应的watcher实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter被调用时,会通知watcher重新计算,从而致使它关联的组件得以更新。

这是一个典型的观察者模式。

关键角色

在 Vue 数据双向绑定的实现逻辑里,有这样三个关键角色:

  • Observer: 它的作用是给对象的属性添加gettersetter,用于依赖收集和派发更新
  • Dep: 用于收集当前响应式对象的依赖关系,每个响应式对象包括子对象都拥有一个Dep实例(里面subsWatcher实例数组),当数据有变更时,会通过dep.notify()通知各个watcher
  • Watcher: 观察者对象 , 实例分为渲染 watcher (render watcher),计算属性 watcher (computed watcher),侦听器 watcher(user watcher)三种

Watcher 和 Dep 的关系

为什么要单独拎出来一小节专门来说这个问题呢?因为大部分同学只是知道:Vue的响应式原理是通过Object.defineProperty实现的。被Object.defineProperty绑定过的对象,会变成「响应式」化。也就是改变这个对象的时候会触发getset事件。

但是对于里面具体的对象依赖关系并不是很清楚,这样也就给了面试官一种:你只是背了答案,对于响应式的内部实现细节,你并不是很清楚的印象。

关于Watcher 和 Dep 的关系这个问题,其实刚开始我也不是很清楚,在查阅了相关资料后,才逐渐对里面的具体实现有了清晰的理解。

刚接触Dep这个词的同学都会比较懵: Dep究竟是用来做什么的呢?我们通过defineReactive方法将data中的数据进行响应式后,虽然可以监听到数据的变化了,那我们怎么处理通知视图就更新呢?

Dep就是帮我们依赖管理的。

如上图所示:一个属性可能有多个依赖,每个响应式数据都有一个Dep来管理它的依赖。

一段话总结原理

上面说了那么多,下面我总结一下Vue响应式的核心设计思路:

当创建Vue实例时,vue会遍历data选项的属性,利用Object.defineProperty为属性添加gettersetter对数据的读取进行劫持(getter用来依赖收集,setter用来派发更新),并且在内部追踪依赖,在属性被访问和修改时通知变化。

每个组件实例会有相应的watcher实例,会在组件渲染的过程中记录依赖的所有数据属性(进行依赖收集,还有computed watcher,user watcher实例),之后依赖项被改动时,setter方法会通知依赖与此datawatcher实例重新计算(派发更新),从而使它关联的组件重新渲染。

到这里,我们已经了解了“套路”,下面让我们用伪代码来实现一下Vue的响应式吧!

核心实现

/**
 * @name Vue数据双向绑定(响应式系统)的实现原理
 */

// observe方法遍历并包装对象属性
function observe(target) {
  // 若target是一个对象,则遍历它
  if (target && typeof target === "Object") {
    Object.keys(target).forEach((key) => {
      // defineReactive方法会给目标属性装上“监听器”
      defineReactive(target, key, target[key]);
    });
  }
}
// 定义defineReactive方法
function defineReactive(target, key, val) {
  const dep = new Dep();
  // 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历
  observe(val);
  // 为当前属性安装监听器
  Object.defineProperty(target, key, {
    // 可枚举
    enumerable: true,
    // 不可配置
    configurable: false,
    get: function () {
      return val;
    },
    // 监听器函数
    set: function (value) {
      dep.notify();
    },
  });
}

class Dep {
  constructor() {
    this.subs = [];
  }

  addSub(sub) {
    this.subs.push(sub);
  }

  notify() {
    this.subs.forEach((sub) => {
      sub.update();
    });
  }
}
查看原文

赞 7 收藏 6 评论 1

前端森林 发布了文章 · 1月13日

小红书面试官:介绍一下 tree shaking 及其工作原理

写在前面

今天这道题目是在和小红书的一位面试官聊的时候:

我:如果要你选择一道题目来考察面试者,你最有可能选择哪一道?

面试官:那应该就是介绍一下tree shaking及其工作原理?

我:为什么?

面试官:是因为最近面了好多同学,大家都说熟悉webpack,在项目中如何去使用、如何去优化,也都或多或少会提到tree shaking,但是每当我深入去问其工作机制或者原理时,却少有人能回答上来。(小声 bb:并不是我想内卷,确实是工程师的基本素养啊,哈哈 😄)

面试官:那你来回答一下这个问题?

我:我也用过tree shaking,只是知道它的别名叫树摇,最早是由Rollup实现,是一种采用删除不需要的额外代码的方式优化代码体积的技术。但是关于它的原理,我还真的不知道,额,,,,

我们平时更多时候是停留在应用层面,这种只是能满足基础的业务诉求,对于后期的技术深挖以及个人的职业发展都是受限的。还是那句老话:知其然,更要知其所以然~

话不多说,下面我就带大家一起来深入探究这个问题。

什么是Tree shaking

Tree shaking 是一种通过清除多余代码方式来优化项目打包体积的技术,专业术语叫 Dead code elimination

这个概念,我相信大多数同学都是了解的。什么,你不懂?

不懂没关系,我可以教你啊(不过那是另外的价钱,哈哈 🙈)

走远了,兄弟,让我们言归正传:tree shaking如何工作的呢?

tree shaking如何工作的呢?

虽然 tree shaking 的概念在 1990 就提出了,但直到 ES6ES6-style 模块出现后才真正被利用起来。

ES6以前,我们可以使用CommonJS引入模块:require(),这种引入是动态的,也意味着我们可以基于条件来导入需要的代码:

let dynamicModule;
// 动态导入
if (condition) {
  myDynamicModule = require("foo");
} else {
  myDynamicModule = require("bar");
}

但是CommonJS规范无法确定在实际运行前需要或者不需要某些模块,所以CommonJS不适合tree-shaking机制。在 ES6 中,引入了完全静态的导入语法:import。这也意味着下面的导入是不可行的:

// 不可行,ES6 的import是完全静态的
if (condition) {
  myDynamicModule = require("foo");
} else {
  myDynamicModule = require("bar");
}

我们只能通过导入所有的包后再进行条件获取。如下:

import foo from "foo";
import bar from "bar";

if (condition) {
  // foo.xxxx
} else {
  // bar.xxx
}

ES6import语法可以完美使用tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

看完上面的分析,你可能还是有点懵,这里我简单做下总结:因为tree shaking只能在静态modules下工作。ECMAScript 6 模块加载是静态的,因此整个依赖树可以被静态地推导出解析语法树。所以在 ES6 中使用 tree shaking 是非常容易的。

tree shaking的原理是什么?

看完上面的分析,相信这里你可以很容易的得出题目的答案了:

  • ES6 Module引入进行静态分析,故而编译的时候正确判断到底加载了那些模块
  • 静态分析程序流,判断那些模块和变量未被使用或者引用,进而删除对应代码

common.js 和 es6 中模块引入的区别?

但到这里,本篇文章还没结束。从这道题目我们可以很容易的引申出来另外一道“明星”面试题:common.js 和 es6 中模块引入的区别?

这道题目来自冴羽大佬的阿里前端攻城狮们写了一份前端面试题答案,请查收

这里就直接贴下他给出的答案了:

CommonJS 是一种模块规范,最初被应用于 Nodejs,成为 Nodejs 的模块规范。运行在浏览器端的 JavaScript 由于也缺少类似的规范,在 ES6 出来之前,前端也实现了一套相同的模块规范 (例如: AMD),用来对前端模块进行管理。自 ES6 起,引入了一套新的 ES6 Module 规范,在语言标准的层面上实现了模块功能,而且实现得相当简单,有望成为浏览器和服务器通用的模块解决方案。但目前浏览器对 ES6 Module 兼容还不太好,我们平时在 Webpack 中使用的 exportimport,会经过 Babel 转换为 CommonJS 规范。在使用上的差别主要有:

1、CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

2、CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

3、CommonJs 是单个值导出,ES6 Module可以导出多个

4、CommonJs 是动态语法可以写在判断里,ES6 Module 静态语法只能写在顶层

5、CommonJsthis 是当前模块,ES6 Modulethisundefined

冴羽大佬的文章质量都非常高,也欢迎大家多去支持冴羽大佬,相信看完一定会对你有所收获。

总结一下

这是大厂面试问题解析的第二篇了,和之前准备写这一系列的初衷一样:我力求通过一些面试题去发掘自己未曾了解或者未曾深入了解的一个领域。

面试题更多时候是一个引子,更多是想通过面试题去思考题目背后带来的对某一模块的深入学习和探讨。

当然,每篇文章也不会只是草草给出答案,我都会尽量深入浅出的给出自己对于这道题目的理解,也会在这个基础上做一些拓展。

查看原文

赞 5 收藏 2 评论 0

前端森林 发布了文章 · 1月11日

字节跳动面试官:请用JS实现Ajax并发请求控制

image

最近也好久没输出文章了,原因很简单,最近巨忙,,,,

讲真的,最近也很迷茫。关于技术、关于生活吧。也找了很多在大厂的朋友去聊,想需求一些后期发展的思路。这其中也聊到了面试,聊到了招聘中会给面试者出的一些题目。我正好也好久没面试了,就从中选了几道。最近也会陆续出一系列关于一些面试问题的解析。

今天这道是字节跳动的:

实现一个批量请求函数 multiRequest(urls, maxNum),要求如下:
• 要求最大并发数 maxNum
• 每当有一个请求返回,就留下一个空位,可以增加新的请求
• 所有请求完成后,结果按照 urls 里面的顺序依次打出

这道题目我想很多同学应该都或多或少的见过,下面我会依次从出现的场景、问题的分析到最终的实现,一步步力求深入浅出的给出这道题目的完整解析。

场景

假设现在有这么一种场景:现有 30 个异步请求需要发送,但由于某些原因,我们必须将同一时刻并发请求数量控制在 5 个以内,同时还要尽可能快速的拿到响应结果。

应该怎么做?

首先我们来了解一下 Ajax的串行和并行。

基于 Promise.all 实现 Ajax 的串行和并行

我们平时都是基于promise来封装异步请求的,这里也主要是针对异步请求来展开。

  • 串行:一个异步请求完了之后在进行下一个请求
  • 并行:多个异步请求同时进行

通过定义一些promise实例来具体演示串行/并行。

串行

var p = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log("1000");
      resolve();
    }, 1000);
  });
};
var p1 = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log("2000");
      resolve();
    }, 2000);
  });
};
var p2 = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log("3000");
      resolve();
    }, 3000);
  });
};

p()
  .then(() => {
    return p1();
  })
  .then(() => {
    return p2();
  })
  .then(() => {
    console.log("end");
  });

如示例,串行会从上到下依次执行对应接口请求。

并行

通常,我们在需要保证代码在多个异步处理之后执行,会用到:

Promise.all((promises: [])).then((fun: function));

Promise.all可以保证,promises数组中所有promise对象都达到resolve状态,才执行then回调。

var promises = function () {
  return [1000, 2000, 3000].map((current) => {
    return new Promise(function (resolve, reject) {
      setTimeout(() => {
        console.log(current);
      }, current);
    });
  });
};

Promise.all(promises()).then(() => {
  console.log("end");
});

Promise.all 并发限制

这时候考虑一个场景:如果你的promises数组中每个对象都是http请求,而这样的对象有几十万个。

那么会出现的情况是,你在瞬间发出几十万个http请求,这样很有可能导致堆积了无数调用栈导致内存溢出。

这时候,我们就需要考虑对Promise.all做并发限制。

Promise.all并发限制指的是,每个时刻并发执行的promise数量是固定的,最终的执行结果还是保持与原来的Promise.all一致。

题目实现

思路分析

整体采用递归调用来实现:最初发送的请求数量上限为允许的最大值,并且这些请求中的每一个都应该在完成时继续递归发送,通过传入的索引来确定了urls里面具体是那个URL,保证最后输出的顺序不会乱,而是依次输出。

代码实现

function multiRequest(urls = [], maxNum) {
  // 请求总数量
  const len = urls.length;
  // 根据请求数量创建一个数组来保存请求的结果
  const result = new Array(len).fill(false);
  // 当前完成的数量
  let count = 0;

  return new Promise((resolve, reject) => {
    // 请求maxNum个
    while (count < maxNum) {
      next();
    }
    function next() {
      let current = count++;
      // 处理边界条件
      if (current >= len) {
        // 请求全部完成就将promise置为成功状态, 然后将result作为promise值返回
        !result.includes(false) && resolve(result);
        return;
      }
      const url = urls[current];
      console.log(`开始 ${current}`, new Date().toLocaleString());
      fetch(url)
        .then((res) => {
          // 保存请求结果
          result[current] = res;
          console.log(`完成 ${current}`, new Date().toLocaleString());
          // 请求没有全部完成, 就递归
          if (current < len) {
            next();
          }
        })
        .catch((err) => {
          console.log(`结束 ${current}`, new Date().toLocaleString());
          result[current] = err;
          // 请求没有全部完成, 就递归
          if (current < len) {
            next();
          }
        });
    }
  });
}
查看原文

赞 10 收藏 6 评论 1

前端森林 发布了文章 · 1月4日

面试官:webpack原理都不会?

image

引言

前一段时间我把webpack源码大概读了一遍,webpack4.x版本后,其源码已经比较庞大,对各种开发场景进行了高度抽象,阅读成本也愈发昂贵。

过度分析源码对于大家并没有太大的帮助。本文主要是想通过分析webpack的构建流程以及实现一个简单的webpack来让大家对webpack的内部原理有一个大概的了解。(保证能看懂,不懂你打我 🙈)
image

webpack 构建流程分析

首先,无须多言,上图~
image
webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:首先会从配置文件和 Shell 语句中读取与合并参数,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Compilerrun来真正启动webpack编译构建过程,webpack的构建流程包括compilemakebuildsealemit阶段,执行完这些阶段就完成了构建过程。

初始化

entry-options 启动

从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。

run 实例化

compiler:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译

编译构建

entry 确定入口

根据配置中的 entry 找出所有的入口文件

make 编译模块

从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理

build module 完成模块编译

经过上面一步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系

seal 输出资源

根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会

emit 输出完成

在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

分析完构建流程,下面让我们自己动手实现一个简易的webpack吧~
image

实现一个简易的 webpack

准备工作

目录结构

我们先来初始化一个项目,结构如下:

|-- forestpack
    |-- dist
    |   |-- bundle.js
    |   |-- index.html
    |-- lib
    |   |-- compiler.js
    |   |-- index.js
    |   |-- parser.js
    |   |-- test.js
    |-- src
    |   |-- greeting.js
    |   |-- index.js
    |-- forstpack.config.js
    |-- package.json

这里我先解释下每个文件/文件夹对应的含义:

  • dist:打包目录
  • lib:核心文件,主要包括compilerparser

    • compiler.js:编译相关。Compiler为一个类, 并且有run方法去开启编译,还有构建modulebuildModule)和输出文件(emitFiles
    • parser.js:解析相关。包含解析ASTgetAST)、收集依赖(getDependencies)、转换(es6转es5
    • index.js:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入
    • test.js:测试文件,用于测试方法函数打console使用
  • src:源代码。也就对应我们的业务代码
  • forstpack.config.js: 配置文件。类似webpack.config.js
  • package.json:这个就不用我多说了~~~(什么,你不知道??)

先完成“造轮子”前 30%的代码

项目搞起来了,但似乎还少点东西~~
image

对了!基础的文件我们需要先完善下:forstpack.config.jssrc

首先是forstpack.config.js

const path = require("path");

module.exports = {
  entry: path.join(__dirname, "./src/index.js"),
  output: {
    path: path.join(__dirname, "./dist"),
    filename: "bundle.js",
  },
};

内容很简单,定义一下入口、出口(你这也太简单了吧!!别急,慢慢来嘛)

其次是src,这里在src目录下定义了两个文件:

  • greeting.js
// greeting.js
export function greeting(name) {
  return "你好" + name;
}
  • index.js
import { greeting } from "./greeting.js";

document.write(greeting("森林"));

ok,到这里我们已经把需要准备的工作都完成了。(问:为什么这么基础?答:当然要基础了,我们的核心是“造轮子”!!)
image

梳理下逻辑

短暂的停留一下,我们梳理下逻辑:

Q: 我们要做什么?

A: 做一个比webpack更强的super webpack(不好意思,失态了,一不小心说出了我的心声)。还是低调点(防止一会被疯狂打脸)
image

Q: 怎么去做?

A: 看下文(23333)

Q: 整个的流程是什么?

A: 哎嘿,大概流程就是:

  • 读取入口文件
  • 分析入口文件,递归的去读取模块所依赖的文件内容,生成AST语法树。
  • 根据AST语法树,生成浏览器能够运行的代码

正式开工

compile.js 编写

const path = require("path");
const fs = require("fs");

module.exports = class Compiler {
  // 接收通过lib/index.js new Compiler(options).run()传入的参数,对应`forestpack.config.js`的配置
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
  // 开启编译
  run() {}
  // 构建模块相关
  buildModule(filename, isEntry) {
    // filename: 文件名称
    // isEntry: 是否是入口文件
  }
  // 输出文件
  emitFiles() {}
};

compile.js主要做了几个事情:

  • 接收forestpack.config.js配置参数,并初始化entryoutput
  • 开启编译run方法。处理构建模块、收集依赖、输出文件等。
  • buildModule方法。主要用于构建模块(被run方法调用)
  • emitFiles方法。输出文件(同样被run方法调用)

到这里,compiler.js的大致结构已经出来了,但是得到模块的源码后, 需要去解析,替换源码和获取模块的依赖项, 也就对应我们下面需要完善的parser.js

parser.js 编写

const fs = require("fs");
// const babylon = require("babylon");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("babel-core");
module.exports = {
  // 解析我们的代码生成AST抽象语法树
  getAST: (path) => {
    const source = fs.readFileSync(path, "utf-8");

    return parser.parse(source, {
      sourceType: "module", //表示我们要解析的是ES模块
    });
  },
  // 对AST节点进行递归遍历
  getDependencies: (ast) => {
    const dependencies = [];
    traverse(ast, {
      ImportDeclaration: ({ node }) => {
        dependencies.push(node.source.value);
      },
    });
    return dependencies;
  },
  // 将获得的ES6的AST转化成ES5
  transform: (ast) => {
    const { code } = transformFromAst(ast, null, {
      presets: ["env"],
    });
    return code;
  },
};

看完这代码是不是有点懵(说好的保证让看懂的 😤)

别着急,你听我辩解!!😷
image

这里要先着重说下用到的几个babel包:

  • @babel/parser:用于将源码生成AST
  • @babel/traverse:对AST节点进行递归遍历
  • babel-core/@babel/preset-env:将获得的ES6AST转化成ES5

parser.js中主要就三个方法:

  • getAST: 将获取到的模块内容 解析成AST语法树
  • getDependencies:遍历AST,将用到的依赖收集起来
  • transform:把获得的ES6AST转化成ES5

完善 compiler.js

在上面我们已经将compiler.js中会用到的函数占好位置,下面我们需要完善一下compiler.js,当然会用到parser.js中的一些方法(废话,不然我上面干嘛要先把parser.js写完~~)
image

直接上代码:

const { getAST, getDependencies, transform } = require("./parser");
const path = require("path");
const fs = require("fs");

module.exports = class Compiler {
  constructor(options) {
    const { entry, output } = options;
    this.entry = entry;
    this.output = output;
    this.modules = [];
  }
  // 开启编译
  run() {
    const entryModule = this.buildModule(this.entry, true);
    this.modules.push(entryModule);
    this.modules.map((_module) => {
      _module.dependencies.map((dependency) => {
        this.modules.push(this.buildModule(dependency));
      });
    });
    // console.log(this.modules);
    this.emitFiles();
  }
  // 构建模块相关
  buildModule(filename, isEntry) {
    let ast;
    if (isEntry) {
      ast = getAST(filename);
    } else {
      const absolutePath = path.join(process.cwd(), "./src", filename);
      ast = getAST(absolutePath);
    }

    return {
      filename, // 文件名称
      dependencies: getDependencies(ast), // 依赖列表
      transformCode: transform(ast), // 转化后的代码
    };
  }
  // 输出文件
  emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = "";
    this.modules.map((_module) => {
      modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
    });

    const bundle = `
        (function(modules) {
          function require(fileName) {
            const fn = modules[fileName];
            const module = { exports:{}};
            fn(require, module, module.exports)
            return module.exports
          }
          require('${this.entry}')
        })({${modules}})
    `;

    fs.writeFileSync(outputPath, bundle, "utf-8");
  }
};

关于compiler.js的内部函数,上面我说过一遍,这里主要来看下emitFiles

emitFiles() {
    const outputPath = path.join(this.output.path, this.output.filename);
    let modules = "";
    this.modules.map((_module) => {
      modules += `'${_module.filename}' : function(require, module, exports) {${_module.transformCode}},`;
    });

    const bundle = `
        (function(modules) {
          function require(fileName) {
            const fn = modules[fileName];
            const module = { exports:{}};
            fn(require, module, module.exports)
            return module.exports
          }
          require('${this.entry}')
        })({${modules}})
    `;

    fs.writeFileSync(outputPath, bundle, "utf-8");
  }

这里的bundle一大坨,什么鬼?
image

我们先来了解下webpack的文件 📦 机制。下面一段代码是经过webpack打包精简过后的代码:

// dist/index.xxxx.js
(function(modules) {
  // 已经加载过的模块
  var installedModules = {};

  // 模块加载函数
  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  __webpack_require__(0);
})([
/* 0 module */
(function(module, exports, __webpack_require__) {
  ...
}),
/* 1 module */
(function(module, exports, __webpack_require__) {
  ...
}),
/* n module */
(function(module, exports, __webpack_require__) {
  ...
})]);

简单分析下:

  • webpack 将所有模块(可以简单理解成文件)包裹于一个函数中,并传入默认参数,将所有模块放入一个数组中,取名为 modules,并通过数组的下标来作为 moduleId
  • modules 传入一个自执行函数中,自执行函数中包含一个 installedModules 已经加载过的模块和一个模块加载函数,最后加载入口模块并返回。
  • __webpack_require__ 模块加载,先判断 installedModules 是否已加载,加载过了就直接返回 exports 数据,没有加载过该模块就通过 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__) 执行模块并且将 module.exports 给返回。

(你上面说的这一坨又是什么鬼?我听不懂啊啊啊啊!!!)
image

那我换个说法吧:

  • 经过webpack打包出来的是一个匿名闭包函数(IIFE
  • modules是一个数组,每一项是一个模块初始化函数
  • __webpack_require__用来加载模块,返回module.exports
  • 通过WEBPACK_REQUIRE_METHOD(0)启动程序

(小声 bb:怎么样,这样听懂了吧)
image

lib/index.js 入口文件编写

到这里,就剩最后一步了(似乎见到了胜利的曙光)。在lib目录创建index.js

const Compiler = require("./compiler");
const options = require("../forestpack.config");

new Compiler(options).run();

这里逻辑就比较简单了:实例化Compiler类,并将配置参数(对应forstpack.config.js)传入。

运行node lib/index.js就会在dist目录下生成bundle.js文件。

(function (modules) {
  function require(fileName) {
    const fn = modules[fileName];
    const module = { exports: {} };
    fn(require, module, module.exports);
    return module.exports;
  }
  require("/Users/fengshuan/Desktop/workspace/forestpack/src/index.js");
})({
  "/Users/fengshuan/Desktop/workspace/forestpack/src/index.js": function (
    require,
    module,
    exports
  ) {
    "use strict";

    var _greeting = require("./greeting.js");

    document.write((0, _greeting.greeting)("森林"));
  },
  "./greeting.js": function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true,
    });
    exports.greeting = greeting;

    function greeting(name) {
      return "你好" + name;
    }
  },
});

和上面用webpack打包生成的js文件作下对比,是不是很相似呢?
image

来吧!展示

我们在dist目录下创建index.html文件,引入打包生成的bundle.js文件:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script data-original="./bundle.js"></script>
  </body>
</html>

此时打开浏览器:
image

如你所愿,得到了我们预期的结果~
image

总结

通过对webpack构建流程的分析以及实现了一个简易的forestpack,相信你对webpack的构建原理已经有了一个清晰的认知!(当然,这里的forestpackwebpack相比还很弱很弱,,,,)
image

查看原文

赞 26 收藏 19 评论 0

前端森林 发布了文章 · 2020-12-24

那些前端开发必不可少的生产力工具

image

引言

一些开源的生产力工具能极大的提升我们的开发效率(我一直是这么认为的 🤠)。

今天推荐一些我一直在用的、比较香的工具给大家。其中包括一些文档、可视化工具、分析工具、代码片段、调试工具等。

Collect UI 🦑

Collect UI画廊是一个免费的在线资源,用于每日UI设计灵感。 目前,它有 6500 多个条目,并且持续保持更新最新内容。
image

在边栏中,有做分类。包括 404 页面、登陆/登出、购物车、日历、视频播放器等。如果你想在某方便需求灵感,然后用于你的公司项目或者个人项目,我想是会有很大的帮助的。

Taskade 📝

在平时生活中总会有很多的事要做,比如工作时有很多待办事项,但是很容易就会忘记一些事情,这时我们就需要一款具有带有待办事项的chrome插件--taskade
image
Taskade简单,整洁并且设计精美,有着令人放松的主题和背景。使用Taskade来整理您的思路,这样您可以集中精力做事情。
image

Colordot 🌈

有时候我们想寻求一个自己喜欢的颜色(有点像起一个自己满意的昵称),却没有灵感,这时候我们就可以来这里

网页区域内随意滑动鼠标,可以产生不同的色彩。确定一个色彩,再随意滑动产生下一个色彩,直到找到自己满意的配色。

FontSpark 🎯

FontSpark是一个帮助有字体选择困难症的用户打造的选择字体的网站,用户只需要输入所需要展示的文字即可获得网站推荐的字体,包括字体类型和大小。
image
对于推荐的字体不是很满意的话,点击Generate按钮刷新即可。

The Noun Project 🎃

image
The Noun Project 网站专门提供高品质、可辨识性强的icon,这些icon没有很炫酷的设计,通常只用单色来呈现,使用者却能很容易地辨别出它要传达的意思。

目前 NounProject 提供超过 200 万的icon供使用者免费下载,且持续在更新中,如果你需要某种icon,却一直没有找到合适的,不妨到这个网站来走走。

csseffects 🚀

CSSeffectsSnippets收录了大约 20 多种CSS动画,无论是加载读取中,或是将光标移动过去产生的动画,都能在网站上即时预览。

而这还不是它最大的亮点,最值得推荐的是所有效果都能在点击后快速复制相关代码,直接让开发者运用到自己的网站或博客,当然可能还是需要经过微调,不过不用从头开始,也不需在网路上寻找这些动画代码,非常方便而且省时。

unDraw 🍉

unDraw 是由希腊设计师 Katerina Limpitsouni 开发的一套开源矢量插图库,在这个网站上有超过 1000 个扁平矢量插画供你下载使用。
image
如果你在做个人网站,但对于插画没有灵感,或许你可以来看看。

DevDocs 🐨

这个网页应用汇聚了各种项目的文档,还支持离线使用。
image
不管是新手程序员还是老程序员都需要有一个可以在线查询各种编程手册的文档,而DevDocs汇集了最全的编程开发文档,又拥有极佳的阅读模式,让你可以快速的查询想要的命令,同时还支持浏览器扩展,可谓方便之极。

CSS Tricks 🦊

CSS Tricks是一个国外的优秀前端开发博客,主要分享使用CSS样式的技巧、经验和教程等。
image
该网站不断的在更新一些优秀的教程和技巧,为前端社区做出了具大的贡献。我也一直在这上面学习,让我在CSS方面视野拓宽了很多。

cssreference 🎾

如果需要更新 CSS 知识或者查询不熟悉、不常用的属性,可以访问这个站点。上面对每个 CSS 属性的讲解很深入,给出的示例也很清楚,便于你理解这些属性并应用于自己的项目。
image

Can I Use

前端开发的时候时常需要检查浏览器的兼容性,在这里推荐(Can I Use)这个是一个针对前端开发人员定制的一个查询CSSJs在各种流行浏览器中的特性和兼容性的网站,可以很好的保证网页的浏览器兼容性。有了这个工具可以快速的了解到代码在各个浏览器中的效果。
image

Lighthouse 🌊

Lighthouse是一个Google开源的自动化工具,主要用于改进网络应用(移动端)的质量。目前测试项包括页面性能、PWA、可访问性(无障碍)、最佳实践、SEO

image

Lighthouse会对各个测试项的结果打分,并给出优化建议,这些打分标准和优化建议可以视为Google的网页最佳实践。

Majestic

Majestic是一款好用的Jest运行测试GUI工具。
imageimage
利用可视化的方式,使用它可以让我们查看测试用例输出日志更加简单。

Wappalyzer 🔭

Wappalyzer是一款能够分析目标网站所采用的平台架构、网站环境、服务器配置环境、javascript框架、编程语言等参数的chrome网站技术分析插件。
image

iHateRegex 🌡

对于开发人员来说,正则表达式是会被经常用到的,很多类型复杂的字符串都可以用它匹配出来,但唯一但缺点是编写起来很困难,不仅需要熟练掌握规则,还需要花时间编写、调试。

iHateRegex就是这样一个帮你解决书写正则表达式烦恼的神器。
image
iHateRegex是一个在线开源工具,可快速检索并匹配到合适的正则表达式,帮你完成如用户名、邮箱、日期、手机号码、密码等常见规则的验证。

当然你也可以看到它内部的匹配过程,这有助于加深你的理解。
image

参考

https://dev.to/joserfelix/40-...

查看原文

赞 37 收藏 28 评论 1

前端森林 发布了文章 · 2020-12-04

你可能不知道的9条Webpack优化策略

image

引言

webpack的打包优化一直是个老生常谈的话题,常规的无非就分块、拆包、压缩等。

本文以我自己的经验向大家分享如何通过一些分析工具、插件以及webpack新版本中的一些新特性来显著提升webpack的打包速度和改善包体积,学会分析打包的瓶颈以及问题所在。

本文演示代码,仓库地址

速度分析 🏂

webpack 有时候打包很慢,而我们在项目中可能用了很多的 pluginloader,想知道到底是哪个环节慢,下面这个插件可以计算 pluginloader 的耗时。

yarn add -D speed-measure-webpack-plugin

配置也很简单,把 webpack 配置对象包裹起来即可:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
  plugins: [
    new MyPlugin(),
    new MyOtherPlugin()
  ]
});

来看下在项目中引入speed-measure-webpack-plugin后的打包情况:
image
从上图可以看出这个插件主要做了两件事情:

  • 计算整个打包总耗时
  • 分析每个插件和 loader 的耗时情况
    知道了具体loaderplugin的耗时情况,我们就可以“对症下药”了

体积分析 🎃

打包后的体积优化是一个可以着重优化的点,比如引入的一些第三方组件库过大,这时就要考虑是否需要寻找替代品了。

这里采用的是webpack-bundle-analyzer,也是我平时工作中用的最多的一款插件了。

它可以用交互式可缩放树形图显示webpack输出文件的大小。用起来非常的方便。

首先安装插件:

yarn add -D webpack-bundle-analyzer

安装完在webpack.config.js中简单的配置一下:

const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
    //  可以是`server`,`static`或`disabled`。
    //  在`server`模式下,分析器将启动HTTP服务器来显示软件包报告。
    //  在“静态”模式下,会生成带有报告的单个HTML文件。
    //  在`disabled`模式下,你可以使用这个插件来将`generateStatsFile`设置为`true`来生成Webpack Stats JSON文件。
    analyzerMode: "server",
    //  将在“服务器”模式下使用的主机启动HTTP服务器。
    analyzerHost: "127.0.0.1",
    //  将在“服务器”模式下使用的端口启动HTTP服务器。
    analyzerPort: 8866,
    //  路径捆绑,将在`static`模式下生成的报告文件。
    //  相对于捆绑输出目录。
    reportFilename: "report.html",
    //  模块大小默认显示在报告中。
    //  应该是`stat`,`parsed`或者`gzip`中的一个。
    //  有关更多信息,请参见“定义”一节。
    defaultSizes: "parsed",
    //  在默认浏览器中自动打开报告
    openAnalyzer: true,
    //  如果为true,则Webpack Stats JSON文件将在bundle输出目录中生成
    generateStatsFile: false,
    //  如果`generateStatsFile`为`true`,将会生成Webpack Stats JSON文件的名字。
    //  相对于捆绑输出目录。
    statsFilename: "stats.json",
    //  stats.toJson()方法的选项。
    //  例如,您可以使用`source:false`选项排除统计文件中模块的来源。
    //  在这里查看更多选项:https:  //github.com/webpack/webpack/blob/webpack-1/lib/Stats.js#L21
    statsOptions: null,
    logLevel: "info"
  )
  ]
}

然后在命令行工具中输入npm run dev,它默认会起一个端口号为 8888 的本地服务器:
image
图中的每一块清晰的展示了组件、第三方库的代码体积。

有了它,我们就可以针对体积偏大的模块进行相关优化了。

多进程/多实例构建 🏈

大家都知道 webpack 是运行在 node 环境中,而 node 是单线程的。webpack 的打包过程是 io 密集和计算密集型的操作,如果能同时 fork 多个进程并行处理各个任务,将会有效的缩短构建时间。

平时用的比较多的两个是thread-loaderHappyPack

先来看下thread-loader吧,这个也是webpack4官方所推荐的。

thread-loader

安装

yarn add -D thread-loader

thread-loader 会将你的 loader 放置在一个 worker 池里面运行,以达到多线程构建。

把这个 loader 放置在其他 loader 之前(如下面示例的位置), 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行。

示例

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve("src"),
        use: [
          "thread-loader",
          // your expensive loader (e.g babel-loader)
        ]
      }
    ]
  }
}

HappyPack

安装

yarn add -D happypack

HappyPack 可以让 Webpack 同一时间处理多个任务,发挥多核 CPU 的能力,将任务分解给多个子进程去并发的执行,子进程处理完后,再把结果发送给主进程。通过多进程模型,来加速代码构建。
image

示例

// webpack.config.js
const HappyPack = require('happypack');

exports.module = {
  rules: [
    {
      test: /.js$/,
      // 1) replace your original list of loaders with "happypack/loader":
      // loaders: [ 'babel-loader?presets[]=es2015' ],
      use: 'happypack/loader',
      include: [ /* ... */ ],
      exclude: [ /* ... */ ]
    }
  ]
};

exports.plugins = [
  // 2) create the plugin:
  new HappyPack({
    // 3) re-add the loaders you replaced above in #1:
    loaders: [ 'babel-loader?presets[]=es2015' ]
  })
];

这里有一点需要说明的是,HappyPack的作者表示已不再维护此项目,这个可以在github仓库看到:
image
作者也是推荐使用webpack官方提供的thread-loader

thread-loaderhappypack 对于小型项目来说打包速度几乎没有影响,甚至可能会增加开销,所以建议尽量在大项目中采用。

多进程并行压缩代码 🛵

通常我们在开发环境,代码构建时间比较快,而构建用于发布到线上的代码时会添加压缩代码这一流程,则会导致计算量大耗时多。

webpack默认提供了UglifyJS插件来压缩JS代码,但是它使用的是单线程压缩代码,也就是说多个js文件需要被压缩,它需要一个个文件进行压缩。所以说在正式环境打包压缩代码速度非常慢(因为压缩JS代码需要先把代码解析成用Object抽象表示的AST语法树,再应用各种规则分析和处理AST,导致这个过程耗时非常大)。

所以我们要对压缩代码这一步骤进行优化,常用的做法就是多进程并行压缩。

目前有三种主流的压缩方案:

  • parallel-uglify-plugin
  • uglifyjs-webpack-plugin
  • terser-webpack-plugin

parallel-uglify-plugin

上面介绍的HappyPack的思想是使用多个子进程去解析和编译JS,CSS等,这样就可以并行处理多个子任务,多个子任务完成后,再将结果发到主进程中,有了这个思想后,ParallelUglifyPlugin 插件就产生了。

webpack有多个JS文件需要输出和压缩时,原来会使用UglifyJS去一个个压缩并且输出,而ParallelUglifyPlugin插件则会开启多个子进程,把对多个文件压缩的工作分给多个子进程去完成,但是每个子进程还是通过UglifyJS去压缩代码。并行压缩可以显著的提升效率。

安装

yarn add -D webpack-parallel-uglify-plugin

示例

import ParallelUglifyPlugin from 'webpack-parallel-uglify-plugin';

module.exports = {
  plugins: [
    new ParallelUglifyPlugin({
      // Optional regex, or array of regex to match file against. Only matching files get minified.
      // Defaults to /.js$/, any file ending in .js.
      test,
      include, // Optional regex, or array of regex to include in minification. Only matching files get minified.
      exclude, // Optional regex, or array of regex to exclude from minification. Matching files are not minified.
      cacheDir, // Optional absolute path to use as a cache. If not provided, caching will not be used.
      workerCount, // Optional int. Number of workers to run uglify. Defaults to num of cpus - 1 or asset count (whichever is smaller)
      sourceMap, // Optional Boolean. This slows down the compilation. Defaults to false.
      uglifyJS: {
        // These pass straight through to uglify-js@3.
        // Cannot be used with uglifyES.
        // Defaults to {} if not neither uglifyJS or uglifyES are provided.
        // You should use this option if you need to ensure es5 support. uglify-js will produce an error message
        // if it comes across any es6 code that it can't parse.
      },
      uglifyES: {
        // These pass straight through to uglify-es.
        // Cannot be used with uglifyJS.
        // uglify-es is a version of uglify that understands newer es6 syntax. You should use this option if the
        // files that you're minifying do not need to run in older browsers/versions of node.
      }
    }),
  ],
};
webpack-parallel-uglify-plugin已不再维护,这里不推荐使用

uglifyjs-webpack-plugin

安装

yarn add -D uglifyjs-webpack-plugin

示例

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
  plugins: [
    new UglifyJsPlugin({
      uglifyOptions: {
        warnings: false,
        parse: {},
        compress: {},
        ie8: false
      },
      parallel: true
    })
  ]
};

其实它和上面的parallel-uglify-plugin类似,也可通过设置parallel: true开启多进程压缩。

terser-webpack-plugin

不知道你有没有发现:webpack4 已经默认支持 ES6语法的压缩。

而这离不开terser-webpack-plugin

安装

yarn add -D terser-webpack-plugin

示例

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: 4,
      }),
    ],
  },
};

预编译资源模块 🚀

什么是预编译资源模块?

在使用webpack进行打包时候,对于依赖的第三方库,比如vuevuex等这些不会修改的依赖,我们可以让它和我们自己编写的代码分开打包,这样做的好处是每次更改我本地代码的文件的时候,webpack只需要打包我项目本身的文件代码,而不会再去编译第三方库。

那么第三方库在第一次打包的时候只打包一次,以后只要我们不升级第三方包的时候,那么webpack就不会对这些库去打包,这样的可以快速的提高打包的速度。其实也就是预编译资源模块

webpack中,我们可以结合DllPluginDllReferencePlugin插件来实现。

DllPlugin是什么?

它能把第三方库代码分离开,并且每次文件更改的时候,它只会打包该项目自身的代码。所以打包速度会更快。

DLLPlugin 插件是在一个额外独立的webpack设置中创建一个只有dllbundle,也就是说我们在项目根目录下除了有webpack.config.js,还会新建一个webpack.dll.js文件。

webpack.dll.js的作用是把所有的第三方库依赖打包到一个bundledll文件里面,还会生成一个名为 manifest.json文件。该manifest.json的作用是用来让 DllReferencePlugin 映射到相关的依赖上去的。

DllReferencePlugin又是什么?

这个插件是在webpack.config.js中使用的,该插件的作用是把刚刚在webpack.dll.js中打包生成的dll文件引用到需要的预编译的依赖上来。

什么意思呢?就是说在webpack.dll.js中打包后比如会生成 vendor.dll.js文件和vendor-manifest.json文件,vendor.dll.js文件包含了所有的第三方库文件,vendor-manifest.json文件会包含所有库代码的一个索引,当在使用webpack.config.js文件打包DllReferencePlugin插件的时候,会使用该DllReferencePlugin插件读取vendor-manifest.json文件,看看是否有该第三方库。

vendor-manifest.json文件就是一个第三方库的映射而已。

怎么在项目中使用?

上面说了这么多,主要是为了方便大家对于预编译资源模块DllPlugin 和、DllReferencePlugin插件作用的理解(我第一次使用看了好久才明白~~)

先来看下完成的项目目录结构:
image

主要在两块配置,分别是webpack.dll.jswebpack.config.js(对应这里我是webpack.base.js

webpack.dll.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'production',
  entry: {
    vendors: ['lodash', 'jquery'],
    react: ['react', 'react-dom']
  },
  output: {
    filename: '[name].dll.js',
    path: path.resolve(__dirname, './dll'),
    library: '[name]'
  },
  plugins: [
    new webpack.DllPlugin({
      name: '[name]',
      path: path.resolve(__dirname, './dll/[name].manifest.json')
    })
  ]
}

这里我拆了两部分:vendors(存放了lodashjquery等)和react(存放了 react 相关的库,reactreact-dom等)

webpack.config.js(对应我这里就是webpack.base.js)

const path = require("path");
const fs = require('fs');
// ...
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');
const webpack = require('webpack');

const plugins = [
  // ...
];

const files = fs.readdirSync(path.resolve(__dirname, './dll'));
files.forEach(file => {
  if(/.*\.dll.js/.test(file)) {
    plugins.push(new AddAssetHtmlWebpackPlugin({
      filepath: path.resolve(__dirname, './dll', file)
    }))
  }
  if(/.*\.manifest.json/.test(file)) {
    plugins.push(new webpack.DllReferencePlugin({
      manifest: path.resolve(__dirname, './dll', file)
    }))
  }
})

module.exports = {
  entry: {
    main: "./src/index.js"
  },
  module: {
    rules: []
  },
  plugins,

  output: {
    // publicPath: "./",
    path: path.resolve(__dirname, "dist")
  }
}

这里为了演示省略了很多代码,项目完整代码在这里

由于上面我把第三方库做了一个拆分,所以对应生成也就会是多个文件,这里读取了一下文件,做了一层遍历。

最后在package.json里面再添加一条脚本就可以了:

"scripts": {
    "build:dll": "webpack --config ./webpack.dll.js",
  },

运行yarn build:dll就会生成本小节开头贴的那张项目结构图了~

利用缓存提升二次构建速度 🍪

一般来说,对于静态资源,我们都希望浏览器能够进行缓存,那样以后进入页面就可以直接使用缓存资源,页面打开速度会显著加快,既提高了用户的体验也节省了宽带资源。

当然浏览器缓存方法有很多种,这里只简单讨论下在webpack中如何利用缓存来提升二次构建速度。

webpack中利用缓存一般有以下几种思路:

  • babel-loader开启缓存
  • 使用cache-loader
  • 使用hard-source-webpack-plugin

babel-loader

babel-loader在执行的时候,可能会产生一些运行期间重复的公共文件,造成代码体积冗余,同时也会减慢编译效率。

可以加上cacheDirectory参数开启缓存:

 {
    test: /\.js$/,
    exclude: /node_modules/,
    use: [{
      loader: "babel-loader",
      options: {
        cacheDirectory: true
      }
    }],
  },

cache-loader

在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘里。

安装

yarn add -D cache-loader

使用

cache-loader 的配置很简单,放在其他 loader 之前即可。修改Webpack 的配置如下:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: [
          'cache-loader',
          ...loaders
        ],
        include: path.resolve('src')
      }
    ]
  }
}
请注意,保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader

hard-source-webpack-plugin

HardSourceWebpackPlugin 为模块提供了中间缓存,缓存默认的存放路径是: node_modules/.cache/hard-source

配置 hard-source-webpack-plugin后,首次构建时间并不会有太大的变化,但是从第二次开始,构建时间大约可以减少 80%左右。

安装

yarn add -D hard-source-webpack-plugin

使用

// webpack.config.js
var HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  entry: // ...
  output: // ...
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}
webpack5中会内置hard-source-webpack-plugin

缩小构建目标/减少文件搜索范围 🍋

有时候我们的项目中会用到很多模块,但有些模块其实是不需要被解析的。这时我们就可以通过缩小构建目标或者减少文件搜索范围的方式来对构建做适当的优化。

缩小构建目标

主要是excludeinclude的使用:

  • exclude: 不需要被解析的模块
  • include: 需要被解析的模块
// webpack.config.js
const path = require('path');
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        // include: path.resolve('src'),
        use: ['babel-loader']
      }
    ]
  }

这里babel-loader就会排除对node_modules下对应 js 的解析,提升构建速度。

减少文件搜索范围

这个主要是resolve相关的配置,用来设置模块如何被解析。通过resolve的配置,可以帮助Webpack快速查找依赖,也可以替换对应的依赖。

  • resolve.modules:告诉 webpack 解析模块时应该搜索的目录
  • resolve.mainFields:当从 npm 包中导入模块时(例如,import * as React from 'react'),此选项将决定在 package.json 中使用哪个字段导入模块。根据 webpack 配置中指定的 target 不同,默认值也会有所不同
  • resolve.mainFiles:解析目录时要使用的文件名,默认是index
  • resolve.extensions:文件扩展名
// webpack.config.js
const path = require('path');
module.exports = {
  ...
  resolve: {
    alias: {
      react: path.resolve(__dirname, './node_modules/react/umd/react.production.min.js')
    }, //直接指定react搜索模块,不设置默认会一层层的搜寻
    modules: [path.resolve(__dirname, 'node_modules')], //限定模块路径
    extensions: ['.js'], //限定文件扩展名
    mainFields: ['main'] //限定模块入口文件名

动态 Polyfill 服务 🦑

介绍动态Polyfill前,我们先来看下什么是babel-polyfill

什么是 babel-polyfill?

babel只负责语法转换,比如将ES6的语法转换成ES5。但如果有些对象、方法,浏览器本身不支持,比如:

  • 全局对象:PromiseWeakMap 等。
  • 全局静态函数:Array.fromObject.assign 等。
  • 实例方法:比如 Array.prototype.includes 等。

此时,需要引入babel-polyfill来模拟实现这些对象、方法。

这种一般也称为垫片

怎么使用babel-polyfill

使用也非常简单,在webpack.config.js文件作如下配置就可以了:

module.exports = {
  entry: ["@babel/polyfill", "./app/js"],
};

为什么还要用动态Polyfill

babel-polyfill由于是一次性全部导入整个polyfill,所以用起来很方便,但与此同时也带来了一个大问题:文件很大,所以后续的方案都是针对这个问题做的优化。

来看下打包后babel-polyfill的占比:
image
占比 29.6%,有点太大了!

介于上述原因,动态Polyfill服务诞生了。
通过一张图来了解下Polyfill Service的原理:
image

每次打开页面,浏览器都会向Polyfill Service发送请求,Polyfill Service识别 User Agent,下发不同的 Polyfill,做到按需加载Polyfill的效果。

怎么使用动态Polyfill服务?

采用官方提供的服务地址即可:

//访问url,根据User Agent 直接返回浏览器所需的 polyfills
https://polyfill.io/v3/polyfill.min.js

Scope Hoisting 🦁

什么是Scope Hoisting

Scope hoisting 直译过来就是「作用域提升」。熟悉 JavaScript 都应该知道「函数提升」和「变量提升」,JavaScript 会把函数和变量声明提升到当前作用域的顶部。「作用域提升」也类似于此,webpack 会把引入的 js 文件“提升到”它的引入者顶部。

Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快。

启用Scope Hoisting

要在 Webpack 中使用 Scope Hoisting 非常简单,因为这是 Webpack 内置的功能,只需要配置一个插件,相关代码如下:

// webpack.config.js
const webpack = require('webpack')

module.exports = mode => {
  if (mode === 'production') {
    return {}
  }

  return {
    devtool: 'source-map',
    plugins: [new webpack.optimize.ModuleConcatenationPlugin()],
  }
}

启用Scope Hoisting后的对比

让我们先来看看在没有 Scope Hoisting 之前 Webpack 的打包方式。

假如现在有两个文件分别是

  • constant.js:
export default 'Hello,Jack-cool';
  • 入口文件 main.js:
import str from './constant.js';
console.log(str);

以上源码用 Webpack 打包后的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__constant_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__constant_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Jack-cool');
  })
]

在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var constant = ('Hello,Jack-cool');
    console.log(constant);
  })
]

从中可以看出开启 Scope Hoisting 后,函数申明由两个变成了一个,constant.js 中定义的内容被直接注入到了 main.js 对应的模块中。 这样做的好处是:

  • 代码体积更小,因为函数申明语句会产生大量代码;
  • 代码在运行时因为创建的函数作用域更少了,内存开销也随之变小。

Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。

由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。

参考

极客时间 【玩转 webpack】

❤️ 爱心三连击

1.如果觉得这篇文章还不错,就帮忙点赞、分享一下吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
image

查看原文

赞 43 收藏 34 评论 1

前端森林 发布了文章 · 2020-11-24

聊一聊前端性能优化 CRP

image

什么是 CRP?

CRP又称关键渲染路径,引用MDN对它的解释:

关键渲染路径是指浏览器通过把 HTML、CSS 和 JavaScript 转化成屏幕上的像素的步骤顺序。优化关键渲染路径可以提高渲染性能。关键渲染路径包含了 Document Object Model (DOM),CSS Object Model (CSSOM),渲染树和布局。

优化关键渲染路径可以提升首屏渲染时间。理解和优化关键渲染路径对于确保回流和重绘可以每秒 60 帧、确保高性能的用户交互和避免无意义渲染至关重要。

如何结合CRP进行性能优化?

我想对于性能优化,大家都不陌生,无论是平时的工作还是面试,是一个老生常谈的话题。

如果单纯针对一些点去泛泛而谈,我想是不太严谨的。

今天我们结合一道非常经典的面试题:从输入URL到页面展示,这中间发生了什么?来从其中的某些环节,来深入谈谈前端性能优化 CRP

从输入 URL 到页面展示,这中间发生了什么?

这道题的经典程度想必不用我多说,这里我用一张图梳理了它的大致流程:
image
这个过程可以大致描述为如下:

1、URI 解析

2、DNS 解析(DNS 服务器)

3、TCP 三次握手(建立客户端和服务器端的连接通道)

4、发送 HTTP 请求

5、服务器处理和响应

6、TCP 四次挥手(关闭客户端和服务器端的连接)

7、浏览器解析和渲染

8、页面加载完成

本文我会从浏览器渲染过程、缓存、DNS 优化几方面进行性能优化的说明。

浏览器渲染过程

构建 DOM 树

构建DOM树的大致流程梳理为下图:
image

我们以下面这段代码为例进行分析:

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <title>构建DOM树</title>
  </head>
  <body>
    <p>森林</p>
    <div>之晨</div>
  </body>
</html>

首先浏览器从磁盘或网络中读取 HTML 原始字节,并根据文件的指定编码将它们转成字符。

然后通过分词器将字节流转换为 Token,在Token(也就是令牌)生成的同时,另一个流程会同时消耗这些令牌并转换成 HTML head 这些节点对象,起始和结束令牌表明了节点之间的关系。
image

当所有的令牌消耗完以后就转换成了DOM(文档对象模型)。

最终构建出的DOM结构如下:
image

构建 CSSOM 树

DOM树构建完成,接下来就是CSSOM树的构建了。

HTML的转换类似,浏览器会去识别CSS正确的令牌,然后将这些令牌转化成CSS节点。

子节点会继承父节点的样式规则,这里对应的就是层叠规则和层叠样式表。

构建DOM树的大致流程可梳理为下图:
image

我们这里采用上面的HTML为例,假设它有如下 css:

body {
  font-size: 16px;
}
p {
  font-weight: bold;
}
div {
  color: orange;
}

那么最终构建出的CSSOM树如下:
image

有了 DOMCSSOM,接下来就可以合成布局树(Render Tree)了。

构建渲染树

DOMCSSOM 都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制 DOM 树的结构,不同之处在于 DOM 树中那些不需要显示的元素会被过滤掉,如 display:none 属性的元素、head 标签、script 标签等。

复制好基本的布局树结构之后,渲染引擎会为对应的 DOM 元素选择对应的样式信息,这个过程就是样式计算。

样式计算

样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。

把 CSS 转换为浏览器能够理解的结构

HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets

转换样式表中的属性值,使其标准化

现在我们已经把现有的 CSS 文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。

什么是属性值标准化?我们来看这样的一段CSS

body {
  font-size: 2em;
}
div {
  font-weight: bold;
}
div {
  color: red;
}

可以看到上面的 CSS 文本中有很多属性值,如 2em、bold、red,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

那标准化后的属性值是什么样子的?

image
从图中可以看到,2em 被解析成了 32pxbold 被解析成了 700red 被解析成了 rgb(255,0,0)……

计算出 DOM 树中每个节点的具体样式

现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,如何计算呢?

这其中涉及到两点:CSS 的继承规则层叠规则

这里由于不是本文的重点,我简单做下说明:

  • CSS 继承就是每个 DOM 节点都包含有父节点的样式
  • 层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。

样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。

计算布局

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局

绘制

通过样式计算和计算布局就完成了最终布局树的构建。再之后,就该进行后续的绘制操作了。

到这里,浏览器的渲染过程就基本结束了,通过下面的一张图来梳理下:
image

到这里我们已经把浏览器解析和渲染的完整流程梳理完成了,那么这其中有那些地方可以去做性能优化呢?

从浏览器的渲染过程中可以做的优化点

通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。

  • 加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
  • 交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
  • 关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。

这里我们需要重点关注加载阶段交互阶段,因为影响到我们体验的因素主要都在这两个阶段,下面我们就来逐个详细分析下。

加载阶段

我们先来分析如何系统优化加载阶段中的页面,来看一个典型的渲染流水线,如下图所示:

image
通过上面对浏览器渲染过程的分析我们知道JavaScript、首次请求的 HTML 资源文件、CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTMLJavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。

这些能阻塞网页首次渲染的资源称为关键资源。而基于关键资源,我们可以继续细化出三个影响页面首次渲染的核心因素:

  • 关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。
  • 关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。
  • 请求关键资源需要多少个RTT(Round Trip Time)RTT 是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。

了解了影响加载过程中的几个核心因素之后,接下来我们就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数降低关键资源大小降低关键资源的 RTT 次数

  • 如何减少关键资源的个数?一种方式是可以将 JavaScriptCSS 改成内联的形式,比如上图的 JavaScriptCSS,若都改成内联模式,那么关键资源的个数就由 3 个减少到了 1 个。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 sync 或者 defer 属性
  • 如何减少关键资源的大小?可以压缩 CSSJavaScript 资源,移除 HTMLCSSJavaScript 文件中一些注释内容
  • 如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。

交互阶段

接下来我们再来聊聊页面加载完成之后的交互阶段以及应该如何去优化。

先来看看交互阶段的渲染流水线:
image
其实这块大致有以下几点可以优化:

  • 避免DOM的回流。也就是尽量避免重排重绘操作。
  • 减少 JavaScript 脚本执行时间。有时JavaScript 函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略:

    • 一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。
    • 另一种是采用 Web Workers
  • DOM操作相关的优化。浏览器有渲染引擎JS引擎,所以当用JS操作DOM时,这两个引擎要通过接口互相“交流”,因此每一次操作DOM(包括只是访问DOM的属性),都要进行引擎之间解析的开销,所以常说要减少 DOM 操作。总结下来有以下几点:

    • 缓存一些计算属性,如let left = el.offsetLeft
    • 通过DOMclass来集中改变样式,而不是通过style一条条的去修改。
    • 分离读写操作。现代的浏览器都有渲染队列的机制。
    • 放弃传统操作DOM的时代,基于vue/react等采用virtual dom的框架
  • 合理利用 CSS 合成动画。合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。
  • CSS选择器优化。我们知道CSS引擎查找是从右向左匹配的。所以基于此有以下几条优化方案:

    • 尽量不要使用通配符
    • 少用标签选择器
    • 尽量利用属性继承特性
  • CSS属性优化。浏览器绘制图像时,CSS的计算也是耗费性能的,一些属性需浏览器进行大量的计算,属于昂贵的属性(box-shadowsborder-radiustransformsfiltersopcity:nth-child等),这些属性在日常开发中经常用到,所以并不是说不要用这些属性,而是在开发中,如果有其它简单可行的方案,那可以优先选择没有昂贵属性的方案。
  • 避免频繁的垃圾回收。我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。

缓存

缓存可以说是性能优化中简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离,减少延迟,并且由于缓存文件可以重复利用,还可以减少带宽,降低网络负荷。下图是浏览器缓存的查找流程图:
image
浏览器缓存相关的知识点还是很多的,这里我有整理一张图:
image
关于浏览器缓存的详细介绍说明,可以参考我之前的这篇文章,这里就不赘述了。

DNS 相关优化

DNS全称Domain Name System。它是互联网的“通讯录”,它记录了域名与实际ip地址的映射关系。每次我们访问一个网站,都要通过各级的DNS服务器查询到该网站的服务器ip,然后才能访问到该服务器。

DNS相关的优化一般涉及到两点:浏览器DNS缓存和DNS预解析。

DNS缓存

一图胜千言:
image

  • 浏览器会先检查浏览器缓存(浏览器缓存有大小和时间限制),时间过长可能导致IP地址变化,无法解析正确IP地址,过短就会让浏览器重复解析域名,一般为几分钟。
  • 如果浏览器缓存没有对应域名,则会去操作系统缓存中查找。
  • 如果还没有找到,域名就会发送到本地区的域名服务器(一般由互联网供应商提供,电信、联通之类),一般在本地区的域名服务器上都能找到了。
  • 当然也可能本地域名服务器也没找到,那本地域名服务器就开始递归查找。

一般而言,浏览器解析DNS需要20-120ms,因此DNS解析可优化之处几乎没有。但存在这样一个场景,网站有很多图片在不同域名下,那如果在登录页就提前解析了之后可能会用到的域名,使解析结果缓存过,这样缩短了DNS解析时间,提高网站整体上的访问速度了,这就是DNS预解析

DNS预解析

来看下 MDN 对于DNS预解析的定义吧:

X-DNS-Prefetch-Control 头控制着浏览器的 DNS 预读取功能。 DNS 预读取是一项使浏览器主动去执行域名解析的功能,其范围包括文档的所有链接,无论是图片的,CSS 的,还是 JavaScript 等其他用户能够点击的 URL

因为预读取会在后台执行,所以 DNS 很可能在链接对应的东西出现之前就已经解析完毕。这能够减少用户点击链接时的延迟。

我们这里就简单看一下如何去做DNS预解析

  • 在页面头部加入,这样浏览器对整个页面进行预解析
<meta http-equiv="x-dns-prefetch-control" content="on" />
  • 通过 link 标签手动添加要解析的域名,比如:
<link rel="dns-prefetch" href="//img10.360buyimg.com" />

参考

李兵 「浏览器工作原理与实践」

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。

4.添加微信fs1263215592,拉你进技术交流群一起学习 🍻
image

查看原文

赞 50 收藏 36 评论 0

前端森林 发布了文章 · 2020-11-16

万物皆可快速上手之Electron(第一弹)

image
最近在开发一款桌面端应用,用到了ElectronReact

React作为日常使用比较频繁的框架,这里就不详细说明了,这里主要是想通过几篇文章让大家快速上手Electron以及与React完美融合。

本篇是系列文章的第一篇,主要是给大家分享Electron的一些概念,让大家对Electron有一个初步的认知。

先来了解一下什么是Electron吧,可能很多小伙伴还没有听过Electron,相信很多小伙伴此时的表情是这样的:

看下官网的自我介绍:

Electron 是一个可以使用 Web 技术如 JavaScriptHTMLCSS 来创建跨平台原生桌面应用的框架。借助 Electron,我们可以使用纯 JavaScript 来调用丰富的原生 APIs

Electronweb 页面作为它的 GUI,而不是绑定了 GUI 库的 JavaScript。它结合了 ChromiumNode.js 和用于调用操作系统本地功能的 APIs(如打开文件窗口、通知、图标等)。

上面这张图很好的说明了Electron的强大之处。

正因如此,现在已经有很多由Electron开发的应用,比如AtomVisual Studio Code等。我们可以在Apps Built on Electron看到所有由Electron构建的项目。

快速开始

前面说了那么多废话,下面进入正题,带大家用五分钟(为什么是五分钟?我猜的 🐶 )的时间运行一个ElectronHello World

安装

这一步很简单:

npm install electron -g

第一个 Electron 应用

一个最简单的 Electron 应用目录结构如下:

hello-world/
├── package.json
├── main.js
└── index.html

package.json的格式和 Node 的完全一致,并且那个被 main 字段声明的脚本文件是你的应用的启动脚本,它运行在主进程上。你应用里的 package.json 看起来应该像:

{
  "name": "hello-world",
  "version": "0.1.0",
  "main": "main.js"
}

创建main.js文件并添加如下代码:

const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
const path = require("path");
let mainWindow;

app.on("ready", () => {
  mainWindow = new BrowserWindow({
    width: 1024,
    height: 680,
    webPreferences: {
      nodeIntegration: true,
      // https://stackoverflow.com/questions/37884130/electron-remote-is-undefined
      enableRemoteModule: true,
    },
  });
  // https://www.electronjs.org/docs/api/browser-window#event-ready-to-show
  // 在加载页面时,渲染进程第一次完成绘制时,如果窗口还没有被显示,渲染进程会发出 ready-to-show 事件 。 在此事件后显示窗口将没有视觉闪烁
  mainWindow.once("ready-to-show", () => {
    mainWindow.show();
  });
  const urlLocation = `file://${__dirname}/index.html`;
  mainWindow.loadURL(urlLocation);
});

然后是index.html文件:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Hello World!</title>
    <style media="screen">
      .version {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>Hi! 我是柯森!</h1>
  </body>
</html>

到这里main.jsindex.htmlpackage.json 这几个文件都有了。万事俱备,来运行这个项目。因为前面已经全局安装了electron,所以我们可以使用 electron 命令来运行项目。在 hello-world/ 目录里面运行下面的命令:

$ electron .

你会发现会弹出一个 electron 应用客户端,如图所示:

到这里,我们已经完成了一个最简单的electron 应用。

但你一定会对上面用到的一些api有疑惑,下面我将带大家深入浅出的了解一下electron的常用概念和api

相关概念

Electron 的进程分为主进程和渲染进程。在说这个之前,我觉得有必要先说一下进程和线程的概念。

进程和线程

这里参考的是廖雪峰老师关于进程和线程概念的阐述,我觉得说的清晰明了。

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

主进程和渲染进程

主进程

electron 里面,运行 package.json 里面 main 脚本的进程被称为主进程。主进程控制整个应用的生命周期,在主进程中可以创建 Web 形式的 GUI,而且整个 Node API 是内置其中。

渲染进程

由于 Electron 使用 Chromium 来展示页面,所以 Chromium 的多进程架构也被充分利用。每个 Electron 的页面都在运行着自己的进程,这样的进程我们称之为渲染进程

在一般浏览器中,网页通常会在沙盒环境下运行,并且不允许访问原生资源。然而,Electron 用户拥有与底层操作系统直接交互的能力。

主进程与渲染进程的区别

主进程使用BrowserWindow实例创建页面。每个BrowserWindow实例都在自己的渲染进程里运行页面。当一个BrowserWindow实例被销毁后,相应的渲染进程也会被终止。

主进程管理所有页面和与之对应的渲染进程。每个渲染进程都是相互独立的,并且只关心他们自己的页面。

electron 中,页面不直接调用底层 APIs,而是通过主进程进行调用。所以如果你想在网页里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

electron 中,主进程和渲染进程的通信主要有以下几种方式:

  • ipcMain、ipcRender
  • Remote 模块

进程通信将稍后在下文详细介绍。

BrowserWindow 的创建

BrowserWindow用于创建和控制浏览器窗口。像上面的hello-world中:

mainWindow = new BrowserWindow({
  width: 1024,
  height: 680,
  webPreferences: {
    nodeIntegration: true,
    // https://stackoverflow.com/questions/37884130/electron-remote-is-undefined
    enableRemoteModule: true,
  },
});

const urlLocation = `file://${__dirname}/index.html`;
mainWindow.loadURL(urlLocation);

创建了一个1024*680的窗口,并通过loadURL方法来加载了一个本地的html文件。

这里一般会通过区分环境加载对应不同的文件。

进程间的通信

在计算机系统设计中,不同的进程间内存资源都是相互隔离的,因此进程间的数据交换,会使用进程间通讯方式达成。而不同于一般的原生应用开发,Electron 的渲染进程与主进程分别属于独立的进程中,而且进程间会存在频繁的数据交换,这时选择一个合理的进程间通讯方式显得尤为重要。下面是 Electron 中官方提供的进程间通讯方式:

window.postMessage,LocalStorage

在前端开发中,鉴于浏览器对本地数据有严格的访问限制,所以一般通过该两种方式进行窗口间的数据通讯,该方式同样适用于 Electron 开发中。然而因为 API 设计目的仅仅是为了前端窗口间简单的数据传输,大量以及频繁的数据通讯会导致应用结构松散,同时传输效率也值得怀疑。

使用IPC进行通信

Electron 中提供了 ipcRenderipcMain 作为主进程以及渲染进程间通讯的桥梁,该方式属于 Electron 特有传输方式,不适用于其他前端开发场景。Electron 沿用 Chromium 中的 IPC 方式,不同于 sockethttp 等通讯方式,Chromium 使用的是命名管道 IPC ,能够提供更高的效率以及安全性。

主进程收发信息

详细参考ipcMain
  • 主进程接收渲染进程发送的信息
ipcMain.on("message", (e, msg) => {
  console.log(msg);
});
  • 主进程(主窗口)发送信息给渲染进程
mainWindow.webContents.send('message', { name: 'from the main by cosen' });

渲染进程收发信息

通过ipcRenderer发送或接收
  • 渲染进程接收主进程发送的信息
ipcRenderer.on("message", (e, msg) => {
  console.log(msg);
});
  • 渲染进程发送信息给主进程
ipcRenderer.send("message", { name: "Cosen" });

使用remote实现跨进程访问

remote 模块提供了一种在渲染进程(网页)和主进程之间进行进程间通讯(IPC)的简便途径。

Electron中, 与GUI相关的模块(如 dialog, menu 等)只存在于主进程,而不在渲染进程中 。为了能从渲染进程中使用它们,需要用ipc模块来给主进程发送进程间消息。使用 remote 模块,可以调用主进程对象的方法,而无需显式地发送进程间消息。

总结

本小节我们大概的了解了Electron的一些概念以及运行了一个入门的hello-world程序。但这远远还不够,下一节我会讲一下如何将ElectronReact完美融合,毕竟还是要更贴近业务的~

好了,不早了,我要去开启我的网易云时光了 🤖

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。
image

查看原文

赞 16 收藏 12 评论 0

前端森林 发布了文章 · 2020-11-13

从 ElementUI 源码的构建流程来看前端 UI 库设计

image

引言

由于业务需要,近期团队要搞一套自己的UI组件库,框架方面还是Vue。而业界已经有比较成熟的一些UI库了,比如ElementUIAntDesignVant等。

结合框架Vue,我们选择在ElementUI基础上进行改造。但造轮子绝非易事,首先需要先去了解它整个但构建流程、目录设计等。

本文通过分析ElementUI完整的构建流程,最后给出搭建一个完备的组件库需要做的一些工作,希望对于想了解ElementUI源码或者也有搭建UI组件库需求的你,可以提供一些帮助!

我们先来看下ElementUI的源码的目录结构。

目录结构解析

  • github:存放了Element UI贡献指南、issuePR模板
  • build:存放了打包相关的配置文件
  • examples:组件相关示例 demo
  • packages:组件源码
  • src:存放入口文件和一些工具辅助函数
  • test:单元测试相关文件,这也是一个优秀的开源项目必备的
  • types:类型声明文件

说完文件目录,剩下还有几个文件(常见的.babelrc.eslintc这里就不展开说明了),在业务代码中是不常见的:
image

  • .travis.yml:持续集成(CI)的配置文件
  • CHANGELOG:更新日志,这里Element UI提供了四种不同语言的,也是很贴心了
  • components.json:标明了组件的文件路径,方便 webpack 打包时获取组件的文件路径。
  • FAQ.md:ElementUI 开发者对常见问题的解答。
  • LICENSE:开源许可证,Element UI使用的是MIT协议
  • Makefile:Makefile 是一个适用于 C/C++ 的工具,在拥有 make 环境的目录下, 如果存在一个 Makefile 文件。 那么输入 make 命令将会执行 Makefile 文件中的某个目标命令。

深入了解构建流程前,我们先来看下ElementUI 源码的几个比较主要的文件目录,这对于后面研究ElementUI的完整流程是有帮助的。

package.json

通常我们去看一个大型项目都是从package.json文件开始看起的,这里面包含了项目的版本、入口、脚本、依赖等关键信息。

我这里拿出了几个关键字段,一一的去分析、解释他的含义。

main

项目的入口文件

import Element from 'element-ui' 时候引入的就是main中的文件

lib/element-ui.common.jscommonjs规范,而lib/index.jsumd规范,这个我在后面的打包模块会详细说明。

files

指定npm publish发包时需要包含的文件/目录。

typings

TypeScript入口文件。

home

项目的线上地址

unpkg

当你把一个包发布到npm上时,它同时应该也可以在unpkg上获取到。也就是说,你的代码既可能在NodeJs环境也可能在浏览器环境执行。为此你需要用umd格式打包,lib/index.jsumd规范,由webpack.conf.js生成。

style

声明样式入口文件,这里是lib/theme-chalk/index.css,后面也会详细说明。

scripts

开发、测试、生产构建,打包、部署,测试用例等相关脚本。scripts算是package.json中最重要的部分了,下面我会一一对其中的重要指令进行说明。
image

bootstrap

"bootstrap": "yarn || npm i"

安装依赖, 官方推荐优先选用yarn(吐槽一句:我刚开始没看明白,想着bootstrap不是之前用过的那个 ui 库吗 🤔,后来看了下,原来bootstrap翻译过来是引导程序的意思,这样看看也就大概理解了 🤣)

build:file

该指令主要用来自动化生成一些文件。

"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js"

这条指令较长,我们拆开来看:

build/bin/iconInit.js

解析icon.scss,把所有的icon的名字放在icon.json里面 最后挂在Vue原型上的$icon上。

最后通过遍历icon.json,得到了官网的这种效果:
image

build/bin/build-entry.js

根据components.json文件,生成src/index.js文件,核心就是json-templater/string插件的使用。

我们先来看下src/index.js文件,他对应的是项目的入口文件,最上面有这样一句:

/* Automatically generated by './build/bin/build-entry.js' */

也就是src/index.js文件是由build/bin/build-entry.js脚本自动构建的。我们来看下源码:

// 根据components.json生成src/index.js文件

// 引入所有组件的依赖关系
var Components = require("../../components.json");
var fs = require("fs");
// https://www.npmjs.com/package/json-templater 可以让string与变量结合 输出一些内容
var render = require("json-templater/string");
// https://github.com/SamVerschueren/uppercamelcase  转化为驼峰 foo-bar >> FooBar
var uppercamelcase = require("uppercamelcase");
var path = require("path");
// os.EOL属性是一个常量,返回当前操作系统的换行符(Windows系统是\r\n,其他系统是\n)
var endOfLine = require("os").EOL;

// 生成文件的名字和路径
var OUTPUT_PATH = path.join(__dirname, "../../src/index.js");
var IMPORT_TEMPLATE =
  "import {{name}} from '../packages/{{package}}/index.js';";
var INSTALL_COMPONENT_TEMPLATE = "  {{name}}";
// var MAIN_TEMPLATE = `/* Automatically generated by './build/bin/build-entry.js' */

// ...

// 获取所有组件的名字,存放在数组中
var ComponentNames = Object.keys(Components);

var includeComponentTemplate = [];
var installTemplate = [];
var listTemplate = [];

ComponentNames.forEach((name) => {
  var componentName = uppercamelcase(name);

  includeComponentTemplate.push(
    render(IMPORT_TEMPLATE, {
      name: componentName,
      package: name,
    })
  );

  if (
    [
      "Loading",
      "MessageBox",
      "Notification",
      "Message",
      "InfiniteScroll",
    ].indexOf(componentName) === -1
  ) {
    installTemplate.push(
      render(INSTALL_COMPONENT_TEMPLATE, {
        name: componentName,
        component: name,
      })
    );
  }

  if (componentName !== "Loading") listTemplate.push(`  ${componentName}`);
});

var template = render(MAIN_TEMPLATE, {
  include: includeComponentTemplate.join(endOfLine),
  install: installTemplate.join("," + endOfLine),
  version: process.env.VERSION || require("../../package.json").version,
  list: listTemplate.join("," + endOfLine),
});

// 结果输出到src/index.js中
fs.writeFileSync(OUTPUT_PATH, template);
console.log("[build entry] DONE:", OUTPUT_PATH);

其实就是上面说的,根据components.json,生成src/index.js文件。

build/bin/i18n.js

根据 examples/i18n/page.json 和模版,生成不同语言的 demo,也就是官网 demo 展示国际化的处理。

ElementUI官网的国际化依据的模版是examples/pages/template,根据不同的语言,分别生成不同的文件:
image
这里面都是.tpl文件,每个文件对应一个模版,而且每个tpl文件又都是符合SFC规范的Vue文件。

我们随便打开一个文件:

export default {
  data() {
    return {
      lang: this.$route.meta.lang,
      navsData: [
        {
          path: "/design",
          name: "<%= 1 >",
        },
        {
          path: "/nav",
          name: "<%= 2 >",
        },
      ],
    };
  },
};

里面都有数字标示了需要国际化处理的地方。

首页所有国际化相关的字段对应关系存储在examples/i18n/page.json中:
image

最终官网展示出来的就是经过上面国际化处理后的页面:
image
支持切换不同语言。

绕了一圈,回到主题:build/bin/i18n.js帮我们做了什么呢?

我们思考一个问题:首页的展示是如何做到根据不同语言,生成不同的vue文件呢?

这就是build/bin/i18n.js帮我们做的事情。

来看下对应的源码:

"use strict";

var fs = require("fs");
var path = require("path");
var langConfig = require("../../examples/i18n/page.json");

langConfig.forEach((lang) => {
  try {
    fs.statSync(path.resolve(__dirname, `../../examples/pages/${lang.lang}`));
  } catch (e) {
    fs.mkdirSync(path.resolve(__dirname, `../../examples/pages/${lang.lang}`));
  }

  Object.keys(lang.pages).forEach((page) => {
    var templatePath = path.resolve(
      __dirname,
      `../../examples/pages/template/${page}.tpl`
    );
    var outputPath = path.resolve(
      __dirname,
      `../../examples/pages/${lang.lang}/${page}.vue`
    );
    var content = fs.readFileSync(templatePath, "utf8");
    var pairs = lang.pages[page];

    Object.keys(pairs).forEach((key) => {
      content = content.replace(
        new RegExp(`<%=\\s*${key}\\s*>`, "g"),
        pairs[key]
      );
    });

    fs.writeFileSync(outputPath, content);
  });
});

处理流程也很简单:遍历examples/i18n/page.json,根据不同的数据结构把tpl文件的标志位,通过正则匹配出来,并替换成自己预先设定好的字段。

这样官网首页的国际化就完成了。

build/bin/version.js

根据package.json中的version,生成examples/versions.json,对应就是完整的版本列表

build:theme

处理样式相关。

"build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk",

同样这一条也关联了多个操作,我们拆开来看。

build/bin/gen-cssfile

这一步是根据components.json,生成package/theme-chalk/index.scss文件,把所有组件的样式都导入到index.scss

其实是做了一个自动化导入操作,后面每次新增组件,就不用手动去引入新增组件的样式了。

gulp build --gulpfile packages/theme-chalk/gulpfile.js

我们都知道ElementUI在使用时有两种引入方式:

  • 全局引入
import Vue from "vue";
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
import App from "./App.vue";

Vue.use(ElementUI);

new Vue({
  el: "#app",
  render: (h) => h(App),
});
  • 按需引入
import Vue from "vue";
import { Pagination, Dropdown } from "element-ui";

import App from "./App.vue";

Vue.use(Pagination);
Vue.use(Dropdown);

new Vue({
  el: "#app",
  render: (h) => h(App),
});

对应两种引入方式,Element在打包时对应的也有两种方案。

具体如下:将packages/theme-chalk下的所有scss文件编译为css,当你需要全局引入时,就去引入index.scss文件;当你按需引入时,引入对应的组件scss文件即可。

这其中有一点,我们需要思考下:如何把packages/theme-chalk下的所有scss文件编译为css

在平时的开发中,我们打包、压缩之类的工作往往都会交给webpack去处理,但是,针对上面这个问题,我们如果采用gulp基于工作流去处理会更加方便。

gulp相关的处理就在packages/theme-chalk/gulpfile.js中:

"use strict";

const { series, src, dest } = require("gulp");
const sass = require("gulp-sass"); // 编译gulp工具
const autoprefixer = require("gulp-autoprefixer"); // 添加厂商前缀
const cssmin = require("gulp-cssmin"); // 压缩css

function compile() {
  return src("./src/*.scss") // src下的所有scss文件
    .pipe(sass.sync()) // 把scss文件编译成css
    .pipe(
      autoprefixer({
        // 基于目标浏览器版本,添加厂商前缀
        browsers: ["ie > 9", "last 2 versions"],
        cascade: false,
      })
    )
    .pipe(cssmin()) // 压缩css
    .pipe(dest("./lib")); // 输出到lib下
}

function copyfont() {
  return src("./src/fonts/**") // 读取src/fonts下的所有文件
    .pipe(cssmin())
    .pipe(dest("./lib/fonts")); // 输出到lib/fonts下
}

exports.build = series(compile, copyfont);

经过处理,最终就会打包出对应的样式文件

cp-cli packages/theme-chalk/lib lib/theme-chalk
cp-cli 是一个跨平台的copy工具,和CopyWebpackPlugin类似

这里就是复制文件到lib/theme-chalk下。

上面提到过多次components.json,下面就来了解下。

components.json

这个文件其实就是记录了组件的路径,在自动化生成文件以及入口时会用到:

{
  "pagination": "./packages/pagination/index.js",
  "dialog": "./packages/dialog/index.js",
  "autocomplete": "./packages/autocomplete/index.js",
  // ...
  "avatar": "./packages/avatar/index.js",
  "drawer": "./packages/drawer/index.js",
  "popconfirm": "./packages/popconfirm/index.js"
}

packages

存放着组件库的源码和组件样式文件。

这里以Alert组件为例做下说明:

Alert 文件夹

image
这里main.vue对应就是组件源码,而index.js就是入口文件:

import Alert from "./src/main";

/* istanbul ignore next */
Alert.install = function (Vue) {
  Vue.component(Alert.name, Alert);
};

export default Alert;

引入组件,然后为组件提供install方法,让Vue可以通过Vue.use(Alert)去使用。

关于install可以看官方文档

packages/theme-chalk

这里面存放的就是所有组件相关的样式,上面也已经做过说明了,里面有index.scss(用于全局引入时导出所有组件样式)和其他每个组件对应的scss文件(用于按需引入时导出对应的组件样式)

src

说了半天,终于绕到了src文件夹。

上面的packages文件夹是分开去处理每个组件,而src的作用就是把所有的组件做一个统一处理,同时包含自定义指令、项目整体入口、组件国际化、组件 mixins、动画的封装和公共方法。
image
我们主要来看下入口文件,也就是src/index.js

/* Automatically generated by './build/bin/build-entry.js' */
// 导入了packages下的所有组件
import Pagination from "../packages/pagination/index.js";
import Dialog from "../packages/dialog/index.js";
import Autocomplete from "../packages/autocomplete/index.js";
// ...

const components = [
  Pagination,
  Dialog,
  Autocomplete,
  // ...
];

// 提供了install方法,帮我们挂载了一些组件与变量
const install = function (Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);
  // 把所有的组件注册到Vue上面
  components.forEach((component) => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  Vue.prototype.$ELEMENT = {
    size: opts.size || "",
    zIndex: opts.zIndex || 2000,
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;
};

/* istanbul ignore if */
if (typeof window !== "undefined" && window.Vue) {
  install(window.Vue);
}
// 导出版本号、install方法(插件)、以及一些功能比如国际化功能
export default {
  version: "2.13.2",
  locale: locale.use,
  i18n: locale.i18n,
  install,
  Pagination,
  Dialog,
  Autocomplete,
  // ...
};

文件开头的:

/* Automatically generated by './build/bin/build-entry.js' */

其实在上面的scriptsbuild/bin/build-entry.js中我们已经提到过:src/index.js是由build-entry脚本自动生成的。

这个文件主要做下以下事情:

  • 导入了 packages 下的所有组件
  • 对外暴露了install方法,把所有的组件注册到Vue上面,并在Vue原型上挂载了一些全局变量和方法
  • 最终将install方法、变量、方法导出

examples

存放了 ElementUI的组件示例。
image
其实从目录结构,我们不难看出这是一个完整独立的Vue项目。主要用于官方文档的展示:
image
这里我们主要关注下docs文件夹:
image
Element官网支持 4 种语言,docs一共有 4 个文件夹,每个文件夹里面的内容基本是一样的。

我们可以看到里面全部都是md文档,而每一个md文档,分别对应着官网组件的展示页面。

其实现在各大主流组件库文档都是用采用md编写。

我们上面大致了解了源码的几个主要文件目录,但是都比较分散。下面我们从构建指令到新建组件、打包流程、发布组件完整的看一下构建流程。

构建流程梳理

构建指令(Makefile)

平时我们都习惯将项目常用的脚本放在package.json中的scripts中。但ElementUI还使用了Makefile文件(由于文件内容较多,这里就选取了几个做下说明):

.PHONY: dist test
default: help

# build all theme
build-theme:
    npm run build:theme

install:
    npm install

install-cn:
    npm install --registry=http://registry.npm.taobao.org

dev:
    npm run dev

play:
    npm run dev:play

new:
    node build/bin/new.js $(filter-out $@,$(MAKECMDGOALS))

dist: install
    npm run dist

deploy:
    @npm run deploy

pub:
    npm run pub

test:
    npm run test:watch

// Tip:
// make new <component-name> [中文]
// 1、将新建组件添加到components.json
// 2、添加到index.scss
// 3、添加到element-ui.d.ts
// 4、创建package
// 5、添加到nav.config.json

我是第一次见,所以就去Google下,网上对Makefile对定义大概是这样:

Makefile 是一个适用于 C/C++ 的工具,较早作为工程化工具出现在 UNIX 系统中, 通过 make 命令来执行一系列的编译和连接操作。在拥有 make 环境的目录下, 如果存在一个 Makefile 文件。 那么输入 make 命令将会执行 Makefile 文件中的某个目标命令。

这里我以make install为例简要说明下执行流程:

  • 执行 make 命令, 在该目录下找到 Makefile 文件。
  • 找到 Makefile 文件中对应命令行参数的 install 目标。这里的目标就是 npm install

构建入口文件

我们看下scripts中的dev指令:

"dev":
"npm run bootstrap &&
npm run build:file &&
cross-env NODE_ENV=development
webpack-dev-server --config build/webpack.demo.js &
node build/bin/template.js",

首先npm run bootstrap是用来安装依赖的。

npm run build:file在前面也有提到,主要用来自动化生成一些文件。主要是node build/bin/build-entry.js,用于生成Element的入口js:先是读取根目录的components.json,这个json文件维护着Element所有的组件路径映射关系,键为组件名,值为组件源码的入口文件;然后遍历键值,将所有组件进行import,对外暴露install方法,把所有import的组件通过Vue.component(name, component)方式注册为全局组件,并且把一些弹窗类的组件挂载到Vue的原型链上(这个在上面介绍scripts相关脚本时有详细说明)。

在生成了入口文件的src/index.js之后就会运行webpack-dev-server

webpack-dev-server --config build/webpack.demo.js

这个前面也提过,用于跑Element官网的基础配置。

新建组件

上面我们提到了,Element中还用了makefile为我们编写了一些额外的脚本。

这里重点说一下 make new <component-name> [中文] 这个命令。

当运行这个命令的时候,其实运行的是 node build/bin/new.js

build/bin/new.js比较简单,备注也很清晰,它帮我们做了下面几件事:

1、新建的组件添加到components.json

2、在packages/theme-chalk/src下新建对应到组件scss文件,并添加到packages/theme-chalk/src/index.scss

3、添加到 element-ui.d.ts,也就是对应的类型声明文件

4、创建package(我们上面有提到组件相关的源码都在package目录下存放)

5、添加到nav.config.json(也就是官网组件左侧的菜单)

打包流程分析

ElementUI打包执行的脚本是:

"dist":
  "npm run clean &&
   npm run build:file &&
   npm run lint &&
   webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js &&
   npm run build:utils &&
   npm run build:umd &&
   npm run build:theme",

下面我们一一来进行分析:

npm run clean(清理文件)

"clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage",

删除之前打包生成文件。

npm run build:file(生成入口文件)

根据components.json生成入口文件src/index.js,以及i18n相关文件。这个在上面已经做过分析,这里就不再展开进行说明。

npm run lint(代码检查)

"lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet",

项目eslint检测,这也是现在项目必备的。

文件打包相关

webpack --config build/webpack.conf.js &&
webpack --config build/webpack.common.js &&
webpack --config build/webpack.component.js
build/webpack.conf.js

生成umd格式的js文件(index.js)

build/webpack.common.js

生成commonjs格式的js文件(element-ui.common.js),require时默认加载的是这个文件。

build/webpack.component.js

components.json为入口,将每一个组件打包生成一个文件,用于按需加载。

npm run build:utils(转译工具方法)

"build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js",

src目录下的除了index.js入口文件外的其他文件通过babel转译,然后移动到lib文件夹下。

npm run build:umd(语言包)

"build:umd": "node build/bin/build-locale.js",

生成umd模块的语言包。

npm run build:theme(生成样式文件)

"build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk",

根据components.json,生成package/theme-chalk/index.scss。用gulp构建工具,编译scss、压缩、输出csslib目录。

最后用一张图来描述上述整个打包流程:
image

发布流程

打包完成,紧跟着就是代码的发布了。Element中发布主要是用shell脚本实现的。

Element发布一共涉及三个部分:

1、git 发布

2、npm 发布

3、官网发布

发布对应的脚本是:

"pub":
  "npm run bootstrap &&
   sh build/git-release.sh &&
   sh build/release.sh &&
   node build/bin/gen-indices.js &&
   sh build/deploy-faas.sh",

sh build/git-release.sh(代码冲突检测)

运行 git-release.sh 进行git冲突的检测,这里主要是检测dev分支是否冲突,因为Element是在dev分支进行开发的。

#!/usr/bin/env sh
# 切换至dev分支
git checkout dev
# 检测本地和暂存区是否还有未提交的文件
if test -n "$(git status --porcelain)"; then
  echo 'Unclean working tree. Commit or stash changes first.' >&2;
  exit 128;
fi
# 检测本地分支是否有误
if ! git fetch --quiet 2>/dev/null; then
  echo 'There was a problem fetching your branch. Run `git fetch` to see more...' >&2;
  exit 128;
fi
# 检测本地分支是否落后远程分支
if test "0" != "$(git rev-list --count --left-only @'{u}'...HEAD)"; then
  echo 'Remote history differ. Please pull changes.' >&2;
  exit 128;
fi
# 通过以上检查,表示代码无冲突
echo 'No conflicts.' >&2;

发布 npm && 官网更新

dev分支代码检测没有冲突,接下来就会执行release.sh脚本,合并dev分支到master、更新版本号、推送代码到远程仓库并发布到npm(npm publish)。

官网更新大致就是:将静态资源生成到examples/element-ui目录下,然后放到gh-pages分支,这样就能通过github pages的方式访问。

到这里ElementUI的完整构建流程就分析完了。

ui 组件库搭建指北

通过对ElementUI源码文件和构建流程的分析,下面我们可以总结一下搭建一个完备的 ui 组件库都需要做什么工作。

目录结构

目录结构对于大型项目是尤其重要的,合理清晰的结构对于后期的开发和扩展都是很有意义的。ui组件库的目录结构,我感觉ElementUI的就很不错:

|-- Element
    |-- .babelrc                           // babel相关配置
    |-- .eslintignore
    |-- .eslintrc                          // eslint相关配置
    |-- .gitattributes
    |-- .gitignore
    |-- .travis.yml                        // ci配置
    |-- CHANGELOG.en-US.md
    |-- CHANGELOG.es.md
    |-- CHANGELOG.fr-FR.md
    |-- CHANGELOG.zh-CN.md                 // 版本改动说明
    |-- FAQ.md                             // 常见问题QA
    |-- LICENSE                            // 版权协议相关
    |-- Makefile                           // 脚本集合(工程化编译)
    |-- README.md                          // 项目说明文档
    |-- components.json                    // 组件配置文件
    |-- element_logo.svg
    |-- package.json
    |-- yarn.lock
    |-- .github                            // 贡献者、issue、PR模版
    |   |-- CONTRIBUTING.en-US.md
    |   |-- CONTRIBUTING.es.md
    |   |-- CONTRIBUTING.fr-FR.md
    |   |-- CONTRIBUTING.zh-CN.md
    |   |-- ISSUE_TEMPLATE.md
    |   |-- PULL_REQUEST_TEMPLATE.md
    |   |-- stale.yml
    |-- build                              // 打包
    |-- examples                           // 示例代码
    |-- packages                           // 组件源码
    |-- src                                // 入口文件以及各种辅助文件
    |-- test                               // 单元测试文件
    |-- types                              // 类型声明

组件开发

参考大多数 UI 组件库的做法,可以将 examples 下的示例代码组织起来并暴露一个入口,使用 webpack 配置一个 dev-server,后续对组件的调试、运行都在此 dev-server 下进行。

单元测试

UI 组件作为高度抽象的基础公共组件,编写单元测试是很有必要的。合格的单元测试也是一个成熟的开源项目必备的。

打包

对于打包后的文件,统一放在 lib 目录下,同时记得要在 .gitignore 中加上 lib 目录,避免将打包结果提交到代码库中。

同时针对引入方式的不同,要提供全局引入(UMD)和按需加载两种形式的包。

文档

组件库的文档一般都是对外可访问的,因此需要部署到服务器上,同时也需具备本地预览的功能。

发布

组件库的某个版本完成开发工作后,需要将包发布到 npm 上。发布流程:

  • 执行测试用例
  • 打包构建
  • 更新版本号
  • npm 包发布
  • 打 tag
  • 自动化部署

维护

发布后需要日常维护之前老版本,一般需要注意一下几点:

  • issue(bug 修复)
  • pull request(代码 pr)
  • CHANGELOG.md(版本改动记录)
  • CONTRIBUTING.md(项目贡献者及规范)

参考

❤️ 爱心三连击

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。
image

查看原文

赞 32 收藏 24 评论 2

前端森林 赞了文章 · 2020-11-10

手把手带你入门前端工程化——超详细教程

本文将分成以下 7 个小节:

  1. 技术选型
  2. 统一规范
  3. 测试
  4. 部署
  5. 监控
  6. 性能优化
  7. 重构

部分小节提供了非常详细的实战教程,让大家动手实践。

另外我还写了一个前端工程化 demo 放在 github 上。这个 demo 包含了 js、css、git 验证,其中 js、css 验证需要安装 VSCode,具体教程在下文中会有提及。

技术选型

对于前端来说,技术选型挺简单的。就是做选择题,三大框架中选一个。个人认为可以依据以下两个特点来选:

  1. 选你或团队最熟的,保证在遇到棘手的问题时有人能填坑。
  2. 选市场占有率高的。换句话说,就是选好招人的。

第二点对于小公司来说,特别重要。本来小公司就不好招人,要是还选一个市场占有率不高的框架(例如 Angular),简历你都看不到几个...

UI 组件库更简单,github 上哪个 star 多就用哪个。star 多,说明用的人就多,很多坑别人都替你踩过了,省事。

统一规范

代码规范

先来看看统一代码规范的好处:

  • 规范的代码可以促进团队合作
  • 规范的代码可以降低维护成本
  • 规范的代码有助于 code review(代码审查)
  • 养成代码规范的习惯,有助于程序员自身的成长

当团队的成员都严格按照代码规范来写代码时,可以保证每个人的代码看起来都像是一个人写的,看别人的代码就像是在看自己的代码。更重要的是我们能够认识到规范的重要性,并坚持规范的开发习惯。

如何制订代码规范

建议找一份好的代码规范,在此基础上结合团队的需求作个性化修改。

下面列举一些 star 较多的 js 代码规范:

css 代码规范也有不少,例如:

如何检查代码规范

使用 eslint 可以检查代码符不符合团队制订的规范,下面来看一下如何配置 eslint 来检查代码。

  1. 下载依赖
// eslint-config-airbnb-base 使用 airbnb 代码规范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
  1. 配置 .eslintrc 文件
{
    "parserOptions": {
        "ecmaVersion": 2019
    },
    "env": {
        "es6": true,
    },
    "parser": "babel-eslint",
    "extends": "airbnb-base",
}
  1. package.jsonscripts 加上这行代码 "lint": "eslint --ext .js test/ src/"。然后执行 npm run lint 即可开始验证代码。代码中的 test/ src/ 是指你要进行校验的代码目录,这里指明了要检查 testsrc 目录下的代码。

不过这样检查代码效率太低,每次都得手动检查。并且报错了还得手动修改代码。

为了改善以上缺点,我们可以使用 VSCode。使用它并加上适当的配置可以在每次保存代码的时候,自动验证代码并进行格式化,省去了动手的麻烦。

css 检查代码规范则使用 stylelint 插件。

由于篇幅有限,具体如何配置请看我的另一篇文章ESlint + stylelint + VSCode自动格式化代码(2020)

在这里插入图片描述

git 规范

git 规范包括两点:分支管理规范、git commit 规范。

分支管理规范

一般项目分主分支(master)和其他分支。

当有团队成员要开发新功能或改 BUG 时,就从 master 分支开一个新的分支。例如项目要从客户端渲染改成服务端渲染,就开一个分支叫 ssr,开发完了再合并回 master 分支。

如果改一个 BUG,也可以从 master 分支开一个新分支,并用 BUG 号命名(不过我们小团队嫌麻烦,没这样做,除非有特别大的 BUG)。

git commit 规范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

大致分为三个部分(使用空行分割):

  1. 标题行: 必填, 描述主要修改类型和内容
  2. 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  3. 页脚注释: 可以写注释,BUG 号链接

type: commit 的类型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代码,以提高性能
  • refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改(例如分号修改)
  • test: 测试用例新增、修改
  • build: 影响项目构建或依赖项修改
  • revert: 恢复上一次提交
  • ci: 持续集成相关文件修改
  • chore: 其他修改(不在上述类型中的修改)
  • release: 发布新版本
  • workflow: 工作流相关文件修改
  1. scope: commit 影响的范围, 比如: route, component, utils, build...
  2. subject: commit 的概述
  3. body: commit 具体修改内容, 可以分为多行.
  4. footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

示例

fix(修复BUG)

如果修复的这个BUG只影响当前修改的文件,可不加范围。如果影响的范围比较大,要加上范围描述。

例如这次 BUG 修复影响到全局,可以加个 global。如果影响的是某个目录或某个功能,可以加上该目录的路径,或者对应的功能名称。

// 示例1
fix(global):修复checkbox不能复选的问题
// 示例2 下面圆括号里的 common 为通用管理的名称
fix(common): 修复字体过小的BUG,将通用管理下所有页面的默认字体大小修改为 14px
// 示例3
fix: value.length -> values.length
feat(添加新功能或新页面)
feat: 添加网站主页静态页面

这是一个示例,假设对点检任务静态页面进行了一些描述。
 
这里是备注,可以是放BUG链接或者一些重要性的东西。
chore(其他修改)

chore 的中文翻译为日常事务、例行工作,顾名思义,即不在其他 commit 类型中的修改,都可以用 chore 表示。

chore: 将表格中的查看详情改为详情

其他类型的 commit 和上面三个示例差不多,就不说了。

验证 git commit 规范

验证 git commit 规范,主要通过 git 的 pre-commit 钩子函数来进行。当然,你还需要下载一个辅助工具来帮助你进行验证。

下载辅助工具

npm i -D husky

package.json 加上下面的代码

"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}

然后在你项目根目录下新建一个文件夹 script,并在下面新建一个文件 verify-commit.js,输入以下代码:

const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

现在来解释下各个钩子的含义:

  1. "pre-commit": "npm run lint",在 git commit 前执行 npm run lint 检查代码格式。
  2. "commit-msg": "node script/verify-commit.js",在 git commit 时执行脚本 verify-commit.js 验证 commit 消息。如果不符合脚本中定义的格式,将会报错。
  3. "pre-push": "npm test",在你执行 git push 将代码推送到远程仓库前,执行 npm test 进行测试。如果测试失败,将不会执行这次推送。

项目规范

主要是项目文件的组织方式和命名方式。

用我们的 Vue 项目举个例子。

├─public
├─src
├─test

一个项目包含 public(公共资源,不会被 webpack 处理)、src(源码)、test(测试代码),其中 src 目录,又可以细分。

├─api (接口)
├─assets (静态资源)
├─components (公共组件)
├─styles (公共样式)
├─router (路由)
├─store (vuex 全局数据)
├─utils (工具函数)
└─views (页面)

文件名称如果过长则用 - 隔开。

UI 规范

UI 规范需要前端、UI、产品沟通,互相商量,最后制定下来,建议使用统一的 UI 组件库。

制定 UI 规范的好处:

  • 统一页面 UI 标准,节省 UI 设计时间
  • 提高前端开发效率

测试

测试是前端工程化建设必不可少的一部分,它的作用就是找出 bug,越早发现 bug,所需要付出的成本就越低。并且,它更重要的作用是在将来,而不是当下。

设想一下半年后,你的项目要加一个新功能。在加完新功能后,你不确定有没有影响到原有的功能,需要测试一下。由于时间过去太久,你对项目的代码已经不了解了。在这种情况下,如果没有写测试,你就得手动一遍一遍的去试。而如果写了测试,你只需要跑一遍测试代码就 OK 了,省时省力。

写测试还可以让你修改代码时没有心理负担,不用一直想着改这里有没有问题?会不会引起 BUG?而写了测试就没有这种担心了。

在前端用得最多的就是单元测试(主要是端到端测试我用得很少,不熟),这里着重讲解一下。

单元测试

单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。

它应该怎么写呢?

  1. 根据正确性写测试,即正确的输入应该有正常的结果。
  2. 根据异常写测试,即错误的输入应该是错误的结果。

对一个函数做测试

例如一个取绝对值的函数 abs(),输入 1,2,结果应该与输入相同;输入 -1,-2,结果应该与输入相反。如果输入非数字,例如 "abc",应该抛出一个类型错误。

对一个类做测试

假设有这样一个类:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

单元测试,必须把这个类的所有方法都测一遍。

对一个组件做测试

组件测试比较难,因为很多组件都涉及了 DOM 操作。

例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。

我们先来回顾一下上传图片的过程:

  1. 点击 <input type="file" />,选择图片上传。
  2. 触发 inputchange 事件,获取 file 对象。
  3. FileReader 将图片转换成 base64 码。

这个过程和下面的代码是一样的:

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) => {
        const fileResult = res.target.result
        console.log(fileResult) // 输出 base64 码
    }

    reader.readAsDataURL(file)
}

上面的代码只是模拟,真实情况下应该是这样使用

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = (res) => {
            const fileResult = res.target.result
            resolve(fileResult) // 输出 base64 码
        }

        reader.readAsDataURL(file)
    })
}

可以看到,上面代码出现了 window 的事件对象 eventFileReader。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:

// 重写 File
window.File = function () {}

// 重写 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            && this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}

然后测试可以这样写:

// 提前写好文件内容
const fileData = 'data:image/test'

// 提供一个假的 file 对象给 tobase64() 函数
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) => {
        tobase64(file).then(base64 => {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 执行测试
test()

通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下。

TDD 测试驱动开发

TDD 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。

TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以到现在为止,三年多的程序员生涯,我还没尝试过 TDD 开发。

虽然环境如此艰难,但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候,可以采用此方法编写测试用例。

测试框架推荐

我常用的测试框架是 jest,好处是有中文文档,API 清晰明了,一看就知道是干什么用的。

部署

在没有学会自动部署前,我是这样部署项目的:

  1. 执行测试 npm run test
  2. 构建项目 npm run build
  3. 将打包好的文件放到静态服务器。

一次两次还行,如果天天都这样,就会把很多时间浪费在重复的操作上。所以我们要学会自动部署,彻底解放双手。

自动部署(又叫持续部署 Continuous Deployment,英文缩写 CD)一般有两种触发方式:

  1. 轮询。
  2. 监听 webhook 事件。

轮询

轮询,就是构建软件每隔一段时间自动执行打包、部署操作。

这种方式不太好,很有可能软件刚部署完我就改代码了。为了看到新的页面效果,不得不等到下一次构建开始。

另外还有一个副作用,假如我一天都没更改代码,构建软件还是会不停的执行打包、部署操作,白白的浪费资源。

所以现在的构建软件基本采用监听 webhook 事件的方式来进行部署。

监听 webhook 事件

webhook 钩子函数,就是在你的构建软件上进行设置,监听某一个事件(一般是监听 push 事件),当事件触发时,自动执行定义好的脚本。

例如 Github Actions,就有这个功能。

对于新人来说,仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程,不需要你提前学习自动化部署的知识,只要照着指引做,就能实现前端项目自动化部署。

前端项目自动化部署——超详细教程(Jenkins、Github Actions),教程已经奉上,各位大佬看完后要是觉得有用,不要忘了点赞,感激不尽。

监控

监控,又分性能监控和错误监控,它的作用是预警和追踪定位问题。

性能监控

性能监控一般利用 window.performance 来进行数据采集。

Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

这个 API 的属性 timing,包含了页面加载各个阶段的起始及结束时间。

在这里插入图片描述
在这里插入图片描述

为了方便大家理解 timing 各个属性的意义,我在知乎找到一位网友对于 timing 写的简介(忘了姓名,后来找不到了,见谅),在此转载一下。

timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。
    navigationStart: 1543806782096,

    // 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventStart: 1543806782523,

    // 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventEnd: 1543806782523,

    // 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。
    redirectStart: 0,

    // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。
    // 如果没有重定向,或者重定向中的一个不同源,这个值会返回0. 
    redirectEnd: 0,

    // 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
    fetchStart: 1543806782096,

    // DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。
    domainLookupStart: 1543806782096,

    // DNS 域名查询完成的时间.
    //如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
    domainLookupEnd: 1543806782096,

    // HTTP(TCP) 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。
    connectStart: 1543806782099,

    // HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
    connectEnd: 1543806782227,

    // HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。
    secureConnectionStart: 1543806782162,

    // 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。
    requestStart: 1543806782241,

    // 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
    responseStart: 1543806782516,

    // 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时
        //(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。
    responseEnd: 1543806782537,

    // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。
    domLoading: 1543806782573,

    // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。
    domInteractive: 1543806783203,

    // 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。
    domContentLoadedEventStart: 1543806783203,

    // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。
    domContentLoadedEventEnd: 1543806783216,

    // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
    domComplete: 1543806783796,

    // load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。
    loadEventStart: 1543806783796,

    // 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
    loadEventEnd: 1543806783802
}

通过以上数据,我们可以得到几个有用的时间

// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),

还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    whiteScreen = new Date() - performance.timing.navigationStart
</script>

通过这几个时间,就可以得知页面首屏加载性能如何了。

另外,通过 window.performance.getEntriesByType('resource') 这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。

在这里插入图片描述

它一般包括以下几个类型

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我们只需用到以下几个信息

// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,

现在,写几行代码来收集这些数据。

// 收集性能信息
const getPerformance = () => {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () => {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item => {
        const arry = resource[item.initiatorType]
        arry && arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小结

通过对性能及资源信息的解读,我们可以判断出页面加载慢有以下几个原因:

  1. 资源过多
  2. 网速过慢
  3. DOM元素过多

除了用户网速过慢,我们没办法之外,其他两个原因都是有办法解决的,性能优化将在下一节《性能优化》中会讲到。

错误监控

现在能捕捉的错误有三种。

  1. 资源加载错误,通过 addEventListener('error', callback, true) 在捕获阶段捕捉资源加载失败错误。
  2. js 执行错误,通过 window.onerror 捕捉 js 错误。
  3. promise 错误,通过 addEventListener('unhandledrejection', callback)捕捉 promise 错误,但是没有发生错误的行数,列数等信息,只能手动抛出相关错误信息。

我们可以建一个错误数组变量 errors 在错误发生时,将错误的相关信息添加到数组,然后在某个阶段统一上报,具体如何操作请看代码

// 捕获资源加载失败错误 js css img...
addEventListener('error', e => {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e => {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason && e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})

小结

通过错误收集,可以了解到网站错误发生的类型及数量,从而可以做相应的调整,以减少错误发生。
完整代码和 DEMO 请看我另一篇文章前端性能和错误监控的末尾,大家可以复制代码(HTML文件)在本地测试一下。

数据上报

性能数据上报

性能数据可以在页面加载完之后上报,尽量不要对页面性能造成影响。

window.onload = () => {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}

当然,你也可以设一个定时器,循环上报。不过每次上报最好做一下对比去重再上报,避免同样的数据重复上报。

错误数据上报

我在DEMO里提供的代码,是用一个 errors 数组收集所有的错误,再在某一阶段统一上报(延时上报)。
其实,也可以改成在错误发生时上报(即时上报)。这样可以避免在收集完错误延时上报还没触发,用户却已经关掉网页导致错误数据丢失的问题。

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}

SPA

window.performance API 是有缺点的,在 SPA 切换路由时,window.performance.timing 的数据不会更新。
所以我们需要另想办法来统计切换路由到加载完成的时间。
拿 Vue 举例,一个可行的办法就是切换路由时,在路由的全局前置守卫 beforeEach 里获取开始时间,在组件的 mounted 钩子里执行 vm.$nextTick 函数来获取组件的渲染完毕时间。

router.beforeEach((to, from, next) => {
    store.commit('setPageLoadedStartTime', new Date())
})
mounted() {
    this.$nextTick(() => {
        this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
    })
}

除了性能和错误监控,其实我们还可以做得更多。

用户信息收集

navigator

使用 window.navigator 可以收集到用户的设备信息,操作系统,浏览器信息...

UV(Unique visitor)

是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。
在用户访问网站时,可以生成一个随机字符串+时间日期,保存在本地。在网页发生请求时(如果超过当天24小时,则重新生成),把这些参数传到后端,后端利用这些信息生成 UV 统计报告。

PV(Page View)

即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

页面停留时间

传统网站
用户在进入 A 页面时,通过后台请求把用户进入页面的时间捎上。过了 10 分钟,用户进入 B 页面,这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。
SPA
可以利用 router 来获取用户停留时间,拿 Vue 举例,通过 router.beforeEachdestroyed 这两个钩子函数来获取用户停留该路由组件的时间。

浏览深度

通过 document.documentElement.scrollTop 属性以及屏幕高度,可以判断用户是否浏览完网站内容。

页面跳转来源

通过 document.referrer 属性,可以知道用户是从哪个网站跳转而来。

小结

通过分析用户数据,我们可以了解到用户的浏览习惯、爱好等等信息,想想真是恐怖,毫无隐私可言。

前端监控部署教程

前面说的都是监控原理,但要实现还是得自己动手写代码。为了避免麻烦,我们可以用现有的工具 sentry 去做这件事。

sentry 是一个用 python 写的性能和错误监控工具,你可以使用 sentry 提供的服务(免费功能少),也可以自己部署服务。现在来看一下如何使用 sentry 提供的服务实现监控。

注册账号

打开 https://sentry.io/signup/ 网站,进行注册。

选择项目,我选的 Vue。

安装 sentry 依赖

选完项目,下面会有具体的 sentry 依赖安装指南。

根据提示,在你的 Vue 项目执行这段代码 npm install --save @sentry/browser @sentry/integrations @sentry/tracing,安装 sentry 所需的依赖。

再将下面的代码拷到你的 main.js,放在 new Vue() 之前。

import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 这里是你的 dsn 地址,注册完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});

然后点击第一步中的 skip this onboarding,进入控制台页面。

如果忘了自己的 DSN,请点击左边的菜单栏选择 Settings -> Projects -> 点击自己的项目 -> Client Keys(DSN)

创建第一个错误

在你的 Vue 项目执行一个打印语句 console.log(b)

这时点开 sentry 主页的 issues 一项,可以发现有一个报错信息 b is not defined

这个报错信息包含了错误的具体信息,还有你的 IP、浏览器信息等等。

但奇怪的是,我们的浏览器控制台并没有输出报错信息。

这是因为被 sentry 屏蔽了,所以我们需要加上一个选项 logErrors: true

然后再查看页面,发现控制台也有报错信息了:

上传 sourcemap

一般打包后的代码都是经过压缩的,如果没有 sourcemap,即使有报错信息,你也很难根据提示找到对应的源码在哪。

下面来看一下如何上传 sourcemap。

首先创建 auth token。

这个生成的 token 一会要用到。

安装 sentry-cli@sentry/webpack-plugin

npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin

安装完上面两个插件后,在项目根目录创建一个 .sentryclirc 文件(不要忘了在 .gitignore 把这个文件添加上,以免暴露 token),内容如下:

[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c

把 xxx 替换成刚才生成的 token。

org 是你的组织名称。

project 是你的项目名称,根据下面的提示可以找到。

在项目下新建 vue.config.js 文件,把下面的内容填进去:

const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目录
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生产环境下上传 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}

填完以后,执行 npm run build,就可以看到 sourcemap 的上传结果了。

我们再来看一下没上传 sourcemap 和上传之后的报错信息对比。

未上传 sourcemap

已上传 sourcemap

可以看到,上传 sourcemap 后的报错信息更加准确。

切换中文环境和时区

选完刷新即可。

性能监控

打开 performance 选项,就能看到你每个项目的运行情况。具体的参数解释请看文档 Performance Monitoring

性能优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

例如压缩文件、使用 CDN 就属于加载时优化;减少 DOM 操作,使用事件委托属于运行时优化。

在解决问题之前,必须先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

手动检查

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    new Date() - performance.timing.navigationStart
</script>

window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。

另外,在 performance 标签下,按 ESC 会弹出来一个小框。点击小框左边的三个点,把 rendering 勾出来。

这两个选项,第一个是高亮重绘区域,另一个是显示帧渲染信息。把这两个选项勾上,然后浏览网页,可以实时的看到你网页渲染变化。

利用工具检查

监控工具

可以部署一个前端监控系统来监控网站性能,上一节中讲到的 sentry 就属于这一类。

chrome 工具 Lighthouse

如果你安装了 Chrome 52+ 版本,请按 F12 打开开发者工具。

它不仅会对你网站的性能打分,还会对 SEO 打分。

使用 Lighthouse 审查网络应用

如何做性能优化

网上关于性能优化的文章和书籍多不胜数,但有很多优化规则已经过时了。所以我写了一篇性能优化文章前端性能优化 24 条建议(2020),分析总结出了 24 条性能优化建议,强烈推荐。

重构

《重构2》一书中对重构进行了定义:

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

重构和性能优化有相同点,也有不同点。

相同的地方是它们都在不改变程序功能的情况下修改代码;不同的地方是重构为了让代码变得更加易读、理解,性能优化则是为了让程序运行得更快。

重构可以一边写代码一边重构,也可以在程序写完后,拿出一段时间专门去做重构。没有说哪个方式更好,视个人情况而定。

如果你专门拿一段时间来做重构,建议你在重构一段代码后,立即进行测试。这样可以避免修改代码太多,在出错时找不到错误点。

重构的原则

  1. 事不过三,三则重构。即不能重复写同样的代码,在这种情况下要去重构。
  2. 如果一段代码让人很难看懂,那就该考虑重构了。
  3. 如果已经理解了代码,但是非常繁琐或者不够好,也可以重构。
  4. 过长的函数,需要重构。
  5. 一个函数最好对应一个功能,如果一个函数被塞入多个功能,那就要对它进行重构了。

重构手法

《重构2》这本书中,介绍了多达上百个重构手法。但我觉得有两个是比较常用的:

  1. 提取重复代码,封装成函数
  2. 拆分太长或功能太多的函数

提取重复代码,封装成函数

假设有一个查询数据的接口 /getUserData?age=17&city=beijing。现在需要做的是把用户数据:{ age: 17, city: 'beijing' } 转成 URL 参数的形式:

let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key => {
    result += '&' + key + '=' + data[key]
})

result.substr(1) // age=17&city=beijing

如果只有这一个接口需要转换,不封装成函数是没问题的。但如果有多个接口都有这种需求,那就得把它封装成函数了:

function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key => {
        result += '&' + key + '=' + data[key]
    })

    return result.substr(1)
}

拆分太长或功能太多的函数

假设现在有一个注册功能,用伪代码表示:

function register(data) {
    // 1. 验证用户数据是否合法
    /**
     * 验证账号
     * 验证密码
     * 验证短信验证码
     * 验证身份证
     * 验证邮箱
     */

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    /**
     * 新建 FileReader 对象
     * 将图片转换成 base64 码
     */

    // 3. 调用注册接口
    // ...
}

这个函数包含了三个功能,验证、转换、注册。其中验证和转换功能是可以提取出来单独封装成函数的:

function register(data) {
    // 1. 验证用户数据是否合法
    // verify()

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    // tobase64()

    // 3. 调用注册接口
    // ...
}

如果你对重构有兴趣,强烈推荐你阅读《重构2》这本书。

参考资料:

总结

写这篇文章主要是为了对我这一年多工作经验作总结,因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手,通过这篇文章入门前端工程化。

如果这篇文章对你有帮助,请点一下赞,感激不尽。

查看原文

赞 133 收藏 86 评论 23

认证与成就

  • 获得 507 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2018-01-26
个人主页被 8.6k 人浏览