蓝色的秋风

蓝色的秋风 查看完整档案

杭州编辑  |  填写毕业院校美团  |  web前端 编辑 qiufeng.blue 编辑
编辑

JavaScript开发爱好者。全栈工程师。

📬微信公众号:秋风的笔记
📘博客主页:https://qiufeng.blue

个人动态

蓝色的秋风 关注了用户 · 11月17日

gaoryrt @gaoryrt

Github 是 https://github.com/gaoryrt
生活博客在 https://gaoryrt.com
狗粮播客在 https://jungle.fm

关注 117

蓝色的秋风 发布了文章 · 11月17日

ES2021 我学不动了,这次只学这 3 个。

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

1.逻辑赋值操作符

你有遇到过这样的情况吗?

function example(a) {
  // Default `a` to "foo"
  if (!a) {
    a = "foo";
  }
  // or
  a = a || "foo";
}

某些初始化的时候需要一些冗长的逻辑代码

function example(opts) {
  // Ok, but could trigger setter.
  opts.foo = opts.foo ?? "bar";

  // No setter, but 'feels wrong' to write.
  opts.baz ?? (opts.baz = "qux");
}

example({ foo: "foo" });

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

function example(opts) {
  // 旧的方式
  if (!a) {
    a = "foo";
  }
  // or
  a = a || "foo";
  // 新的方式
  a ||= "foo"
}

example({ foo: "foo" });
function example(opts) {
  // 旧的方式
  opts.foo = opts.foo ?? "bar";
  // 新的方式
  opts.foo ??= "bar";

  // 旧的方式
  opts.baz ?? (opts.baz = "qux");
  // 新的方式
  opts.baz ??= "qux";
}

example({ foo: "foo" });

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

a = a + b;
a += b;
a = a - b;
a -= b;

在这里插入图片描述

2.Promise.any

Promise.any。 从字面意思来看,相信聪明的你应该能大致猜出这个 API 的作用。Promise.any 接受一个 Promise 的数组。当其中任何一个 Promise 完成(fullfill)时,就返回那个已经有完成值的 Promise。如果所有的 Promise 都拒绝(reject),则返回一个拒绝的 Promise,该 Promise 的返回值是一个 AggregateError 对象。

Promise.any(promises).then(
  (first) => {
    // 任意一个Promise完成了
  },
  (error) => {
    // 所有Promise都被拒绝了
  }
);

在这里插入图片描述

Promise.any([
  fetch("https://v8.dev/").then(() => "home"),
  fetch("https://v8.dev/blog").then(() => "blog"),
  fetch("https://v8.dev/docs").then(() => "docs"),
])
  .then((first) => {
    // Any of the promises was fulfilled.
    console.log(first);
    // → 'home'
  })
  .catch((error) => {
    // All of the promises were rejected.
    console.log(error);
  });

例如一些播放平台,可以通过这个来测试当前延迟最低的线路是哪个,优先切换到对应的最快的线路。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
来,亮出祖传降级代码

function reverse(promise) {
  return new Promise((resolve, reject) =>
    Promise.resolve(promise).then(reject, resolve)
  );
}
function promiseAny(iterable) {
  return reverse(Promise.all([...iterable].map(reverse)));
}
// https://github.com/m0ppers/promise-any/blob/master/index.js

实现很简单,通过一个反转函数,利用 Promisea.all 的特性,只要一个 Promise 被拒绝了,就进入到 reject,因此反转 resolvereject 就能模拟出 Promise.any 了。
在这里插入图片描述
1605350041175在这里插入图片描述

3.数字分隔符

let fee = 1000000000;
let fee = 1_000_000_000;

这个模式不仅在十进制可以用,二进制,十六进制....甚至 BigInt,都可以使用。

// Binary Literals
let nibbles = 0b1010_0001_1000_0101;
// Hex Literal
let message = 0xa0_b0_c0;
// BigInt Literal
const max = 2n ** (64n - 1n) - 1n;
console.log(max === 9_223_372_036_854_775_807n);

以上特性均在最新版 chrome 支持,快打开控制台玩耍吧。

如果想要在实际项目中使用,请使用以下两个插件。

最后

image

查看原文

赞 12 收藏 6 评论 10

蓝色的秋风 发布了文章 · 11月13日

2020前端开发者11个必备的网站

网上有很多很棒的工具,让作为前端开发人员的我们生活的更加轻松。在这篇文章中,我将快速介绍一下我在开发工作中经常使用的11种工具。

Node.green

用来查询当前 Node 版本是否某些功能。例如,对象展开符( Rest/Spread Properties)

1582372545876.jpg

可以看到在 Node v8.3.0 以下是不支持的。分别在 Node v8.5.0v8.2.1 下运行以下代码片段

const a = { foo: 1};
console.log({...a, b: 2}); 

1582372779948.jpg

当你遇到以上错误,那大多就是 Node 版本问题啦。

在线地址: https://node.green/

CanIUse

当你想要确定某个 Web API 的兼容性的时候,这个在线工具将轻松搞定。

假设我们想知道哪些浏览器及其版本将支持 Web Share API:navigator.share(...

1_pq1UczjJ8dhTsO6hCPntyw.png

查看结果。浏览器和支持navigator.share(…)的版本都列出了。

在线地址: https://caniuse.com/

Minify

为了减少应用程序代码的包大小,我们在发布到到生产环境的时候,需要使它们最小化。 最小化消除了空格,无效代码等。这能够使应用程序包大小的显着减小,从而节省浏览器上的加载时间。(虽然在当下,有 webpack uglifyJS 等插件,但是当我在开发非打包的简单应用的时候,这个是一个不错的选择。 )

1582373652825.jpg

在线地址: https://www.minifier.org/

Bit.dev

Bit.dev是一个非常棒的组件中心。 可以用它来托管,记录和管理来自不同项目的可复用组件。 这是增加代码复用,加速开发并优化团队协作的好方法。

这也是从头开始构建设计系统的不错选择(因为它本质上具有设计系统所需的一切)。 Bit.devBit完美配合,Bit是处理组件隔离和发布的开源工具。

Bit.dev支持React,带有TypeScriptReactAngularVue等。

1_Nj2EzGOskF51B5AKuR-szw.gif

在线地址: https://bit.dev/

Unminify

免费的在线工具,用于最小化(解压,反混淆)JavaScript,CSS和HTML代码,使其可读性强,美观

1582375400913.jpg

在线地址: https://unminify.com/

Stackblitz

这是每个人都喜欢的工具。Stackblitz使我们能够使用世界上最流行和使用最多的IDE,即web上的Visual Studio代码。

只需单击一下,Stackblitz 即可快速提供AngularReactVueVanillaRxJSTypeScript项目的框架。

当你想从浏览器中尝试一段代码或任何当前JS框架中的功能时,Stackblitz非常有用。 假设你正在阅读Angular文章,并且遇到了想要尝试的代码。 您可以最小化您的浏览器并快速搭建一个新的Angular项目。

还有其他很棒的在线IDE,但是我相信Stackblitz的转折点是使用每个人都喜欢的 Visual Studio Code感觉和工具。 (ps: 本人使用体验,非常快速流畅, 附上图,比 sandbox 快很多)

1582374042909.jpg

在线地址: https://stackblitz.com/

JWT.io

如果您使用JSON Web令牌(JWT)保护应用程序安全,或者使用JWT允许用户访问后端的受保护资源。

决定是否应访问路线或资源的一种方法是检查令牌的到期时间。 有时候我们想要解码JWT以查看其有效 payload,jwt.io恰好提供了这一点。

这个在线工具使我们能够插入令牌以查看其有效 payload。 一旦我们粘贴了令牌,jwt.io便对该令牌进行解码并显示其有效payload

1582374387059.jpg

在线地址: https://jwt.io/

BundlePhobia

您是否曾经不确定过node_modules的大小,或者只是想知道将pakckage.json安装在您的计算机中的大小? BundlePhobia提供了答案

1582374462632.jpg

该工具使我们能够加载package.json文件,并显示将从package.json安装的依赖项的大小,也可以查询单包的体积。

在线地址: https://bundlephobia.com/

Babel REPL

Babel是一个免费的开放源代码JS转编译器,用于将现代ES代码转换为普通的 ES5 JavaScript。

该工具是Babeljs团队在网上建立的Web应用,可以将 ES6 +代码转换为ES5。

本人总结的两个比较方便的使用方式

  1. 方面面试时在线写高级语法。
  2. 可以快速查看某些 polyfill 是怎么写的。

1582374539633.jpg

在线地址: https://babeljs.io/en/repl

Prettier Playground

Prettier是一个自以为是的JS代码格式化程序。 它通过解析代码并使用JS最佳编码实践将其重新打印来实施一致的样式。

该工具已在我们的开发环境中广泛使用,但它也具有一个在线地址,你可以在其中美化您的代码。

1582375260418.jpg

在线地址: https://prettier.io/playground

postwoman

postwoman 是一款功能强大的网页调试和模拟发送HTTP请求的Chrome插件,支持几乎所有类型的HTTP请求,操作简单且方便。可用于接口测试,比如测试你用easy-mock生成的接口。

1582374841427.jpg

在线地址: https://postwoman.io/

本文翻译自 https://blog.bitsrc.io/12-use... 但是不仅仅是单纯地翻译,替换了原文中一些我觉得不太实用的并加入一些自己的总结。

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

查看原文

赞 26 收藏 19 评论 0

蓝色的秋风 赞了文章 · 11月12日

【编译篇】AST实现函数错误的自动上报

前言

之前有身边有人问我在错误监控中,如何能实现自动为函数自动添加错误捕获。今天我们来聊一聊技术如何实现。先讲原理:在代码编译时,利用 babel 的 loader,劫持所有函数表达。然后利用 AST(抽象语法树) 修改函数节点,在函数外层包裹 try/catch。然后在 catch 中使用 sdk 将错误信息在运行时捕获上报。如果你对编译打包感兴趣,那么本文就是为你准备的。

本文涉及以下知识点:

  • [x] AST
  • [x] npm 包开发
  • [x] Babel
  • [x] Babel plugin
  • [x] Webpack loader

实现效果

Before 开发环境:

var fn = function(){
  console.log('hello');
}

After 线上环境:

var fn = function(){
+  try {
    console.log('hello');
+  } catch (error) {
+    // sdk 错误上报
+    ErrorCapture(error);
+  }
}

Babel 是什么?

Babel 是JS编译器,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。
简单说就是从一种源码到另一种源码的编辑器!下面列出的是 Babel 能为你做的事情:

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性 (通过 @babel/polyfill 模块)
  • 源码转换 (codemods)
  • 其它

Babel 的运行主要分三个阶段,请牢记:解析->转换->生成,后面会用到。

本文我们将会写一个 Babel plugin 的 npm 包,用于编译时将代码进行改造。

babel-plugin 环境搭建

这里我们使用 yeomangenerator-babel-plugin 来构建插件的脚手架代码。安装:

$ npm i -g yo
$ npm i -g generator-babel-plugin

然后新建文件夹:

$ mkdir babel-plugin-function-try-actch
$ cd babel-plugin-function-try-actch

生成npm包的开发工程:

$ yo babel-plugin


此时项目结构为:

babel-plugin-function-try-catch
├─.babelrc
├─.gitignore
├─.npmignore
├─.travis.yml
├─README.md
├─package-lock.json
├─package.json
├─test
|  ├─index.js
|  ├─fixtures
|  |    ├─example
|  |    |    ├─.babelrc
|  |    |    ├─actual.js
|  |    |    └expected.js
├─src
|  └index.js
├─lib
|  └index.js

这就是我们的 Babel plugin,取名为 babel-loader-function-try-catch为方便文章阅读,以下我们统一简称为plugin)。

至此,npm 包环境搭建完毕,代码地址

调试 plugin 的 ast

开发工具

本文前面说过 Babel 的运行主要分三个阶段:解析->转换->生成,每个阶段 babel 官方提供了核心的 lib:

  • babel-core。Babel 的核心库,提供了将代码编译转化的能力。
  • babel-types。提供 AST 树节点的类型。
  • babel-template。可以将普通字符串转化成 AST,提供更便捷的使用

plugin 根目录安装需要用到的工具包:

npm i @babel/core @babel/parser babel-traverse @babel/template babel-types -S

打开 plugin 的 src/index.js 编辑:

const parser = require("@babel/parser");

// 先来定义一个简单的函数
let source = `var fn = function (n) {
  console.log(111)
}`;

// 解析为 ast
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 打印一下看看,是否正常
console.log(ast);

终端执行 node src/index.js 后将会打印如下结果:

这就是 fn 函数对应的 ast,第一步解析完成!

获取当前节点的 AST

然后我们使用 babel-traverse 去遍历对应的 AST 节点,我们想要寻找所有的 function 表达可以写在 FunctionExpression 中:

打开 plugin 的 src/index.js 编辑:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;

// mock 待改造的源码
let source = `var fn = function() {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
+ traverse(ast, {
+   FunctionExpression(path, state) { // Function 节点
+     // do some stuff
+   },
+ });

所有函数表达都会走到 FunctionExpression 中,然后我们可以在里面对其进行修改。
其中参数 path 用于访问到当前的节点信息 path.node,也可以像 DOM 树访问到父节点的方法 path.parent

修改当前节点的 AST

好了,接下来要做的是在 FunctionExpression 中去劫持函数的内部代码,然后将其放入 try 函数内,并且在 catch 内加入错误上报 sdk 的代码段。

获取函数体内部代码

上面定义的函数是

var fn = function() {
  console.log(111)
}

那么函数内部的代码块就是 console.log(111),可以使用 path 拿到这段代码的 AST 信息,如下:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;

// mock 待改造的源码
let source = `var fn = function(n) {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // 函数表达式会进入当前方法
+    // 获取函数当前节点信息
+    var node = path.node,
+        params = node.params,
+        blockStatement = node.body,
+        isGenerator = node.generator,
+        isAsync = node.async;

+    // 可以尝试打印看看结果
+    console.log(node, params, blockStatement);
  },
});

终端执行 node src/index.js,可以打印看到当前函数的 AST 节点信息。

创建 try/catch 节点(两步骤)

创建一个新的节点可能会稍微陌(fu)生(za)一点,不过我已经为大家总结了我个人的经验(仅供参考)。首先需要知道当前新增代码段它的声明是什么,然后使用 @babel-types 去创建即可。

第一步:

那么我们如何知道它的表达声明type是什么呢?这里我们可以 使用 astexplorer 查找它在 AST 中 type 的表达

如上截图得知,try/catch 在 AST 中的 type 就是 TryStatement

第二步:

然后去 @babel-types 官方文档查找对应方法,根据 API 文档来创建即可。

如文档所示,创建一个 try/catch 的方式使用 t.tryStatement(block, handler, finalizer)

创建新的ast节点一句话总结:使用 astexplorer 查找你要生成的代码的 type,再根据 type 在 @babel-types 文档查找对应的使用方法使用即可!

那么创建 try/catch 只需要使用 t.tryStatement(try代码块, catch代码块) 即可。

  • try代码块 表示 try 中的函数代码块,即原先函数 body 内的代码 console.log(111),可以直接用 path.node.body 获取;
  • catch代码块 表示 catch 代码块,即我们想要去改造进行错误收集上报的 sdk 的代码 ErrorCapture(error),可以使用 @babel/template 去生成。

代码如下所示:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");

// 0、定义一个待处理的函数(mock)
let source = `var fn = function() {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // Function 节点
    var node = path.node,
        params = node.params,
        blockStatement = node.body, // 函数function内部代码,将函数内部代码块放入 try 节点
        isGenerator = node.generator,
        isAsync = node.async;

+    // 创建 catch 节点中的代码
+    var catchStatement = template.statement(`ErrorCapture(error)`)();
+    var catchClause = t.catchClause(t.identifier('error'),
+          t.blockStatement(
+            [catchStatement] //  catchBody
+          )
+        );
+    // 创建 try/catch 的 ast
+    var tryStatement = t.tryStatement(blockStatement, catchClause);
  }
});

创建新函数节点,并将上面定义好的 try/catch 塞入函数体:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");

// 0、定义一个待处理的函数(mock)
let source = `var fn = function() {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // Function 节点
      var node = path.node,
          params = node.params,
          blockStatement = node.body, // 函数function内部代码,将函数内部代码块放入 try 节点
          isGenerator = node.generator,
          isAsync = node.async;

      // 创建 catch 节点中的代码
      var catchStatement = template.statement(`ErrorCapture(error)`)();
      var catchClause = t.catchClause(t.identifier('error'),
            t.blockStatement(
              [catchStatement] //  catchBody
            )
          );
      // 创建 try/catch 的 ast
      var tryStatement = t.tryStatement(blockStatement, catchClause);

+    // 创建新节点
+    var func = t.functionExpression(node.id, params, t.BlockStatement([tryStatement]), isGenerator, isAsync);
+    // 打印看看是否成功
+    console.log('当前节点是:', func);
+    console.log('当前节点下的自节点是:', func.body);
  }
});

此时将上述代码在终端执行 node src/index.js

可以看到此时我们在一个函数表达式 body 中创建了一个 try 函数(TryStatement)。
最后我们需要将原函数节点进行替换:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");

// 0、定义一个待处理的函数(mock)
let source = `var fn = function() {...

// 1、解析
let ast = parser.parse(source, {...

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // Function 节点
      var node = path.node,
          params = node.params,
          blockStatement = node.body, // 函数function内部代码,将函数内部代码块放入 try 节点
          isGenerator = node.generator,
          isAsync = node.async;

      // 创建 catch 节点中的代码
      var catchStatement = template.statement(`ErrorCapture(error)`)();
      var catchClause = t.catchClause(t.identifier('error'),...

      // 创建 try/catch 的 ast
      var tryStatement = t.tryStatement(blockStatement, catchClause);
      // 创建新节点
      var func = t.functionExpression(node.id, params, t.BlockStatement([tryStatement]), isGenerator, isAsync);
      
+    // 替换原节点
+    path.replaceWith(func);
  }
});

+ // 将新生成的 AST,转为 Source 源码:
+ return core.transformFromAstSync(ast, null, {
+  configFile: false // 屏蔽 babel.config.js,否则会注入 polyfill 使得调试变得困难
+ }).code;

“A loader is a node module exporting a function”,也就是说一个 loader 就是一个暴露出去的 node 模块,既然是一个node module,也就基本可以写成下面的样子:

module.exports = function() {
    //  ...
};

再编辑 src/index.js 为如下截图:

边界条件处理

我们并不需要为所有的函数都增加 try/catch,所有我们还得处理一些边界条件。

  • 1、如果有 try catch 包裹了
  • 2、防止 circle loops
  • 3、需要 try catch 的只能是语句,像 () => 0 这种的 body
  • 4、如果函数内容小于多少行数

满足以上条件就 return 掉!

代码如下:

if (blockStatement.body && t.isTryStatement(blockStatement.body[0])
  || !t.isBlockStatement(blockStatement) && !t.isExpressionStatement(blockStatement)
  || blockStatement.body && blockStatement.body.length <= LIMIT_LINE) {
  return;
}

最后我们发布到 npm 平台 使用。

由于篇幅过长不易阅读,本文特别的省略了本地调试过程,所以需要调试请移步 [【利用AST自动为函数增加错误上报-续集】有关 npm 包的本地开发和调试]()。

如何使用

npm install babel-plugin-function-try-catch

webpack 配置

rules: [{
  test: /\.js$/,
  exclude: /node_modules/,
  use: [
+   "babel-plugin-function-try-catch",
    "babel-loader",
  ]
}]

效果见如下图所示:

最后

有关 npm 包的本地调试见下篇: 有关 npm 包的本地开发和调试

更多 AST 相关请关注后面分享,谢谢。

Reference:

完整代码地址请点击

Babel 插件手册点击

查看原文

赞 14 收藏 7 评论 6

蓝色的秋风 赞了文章 · 11月11日

前端项目自动化部署——超详细教程(Jenkins、Github Actions)

本教程主要讲解了怎么使用 Jenkins 和 Github Actions 部署前端项目。

  1. 第一部分是使用 Gitea 配置局域网 git 服务器,再使用 Jenkins 将 Gitea 下的项目部署到局域网服务器。
  2. 第二部分是使用 Github Actions 将 Github 项目部署到 Github Page 和阿里云。

阅读本教程并不需要你提前了解 Jenkins 和 Github Actions 的知识,只要按照本教程的指引,就能够实现自动化部署项目。

PS:本人所用电脑操作系统为 windows,即以下所有的操作均在 windows 下运行。其他操作系统的配置大同小异,不会有太大差别。

Gitea + Jenkins 自动构建前端项目并部署到服务器

Gitea 用于构建 Git 局域网服务器,Jenkins 是 CI/CD 工具,用于部署前端项目。

配置 Gitea

  1. 下载 Gitea,选择一个喜欢的版本,例如 1.13,选择 gitea-1.13-windows-4.0-amd64.exe 下载。
  2. 下载完后,新建一个目录(例如 gitea),将下载的 Gitea 软件放到该目录下,双击运行。
  3. 打开 localhost:3000 就能看到 Gitea 已经运行在你的电脑上了。
  4. 点击注册,第一次会弹出一个初始配置页面,数据库选择 SQLite3。另外把 localhost 改成你电脑的局域网地址,例如我的电脑 IP 为 192.168.0.118

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

  1. 填完信息后,点击立即安装,等待一会,即可完成配置。
  2. 继续点击注册用户,第一个注册的用户将会成会管理员。
  3. 打开 Gitea 的安装目录,找到 custom\conf\app.ini,在里面加上一行代码 START_SSH_SERVER = true。这时就可以使用 ssh 进行 push 操作了。

在这里插入图片描述

  1. 如果使用 http 的方式无法克隆项目,请取消 git 代理。
git config --global --unset http.proxy
git config --global --unset https.proxy

配置 Jenkins

  1. 需要提前安装 JDK,JDK 安装教程网上很多,请自行搜索。
  2. 打开 Jenkins 下载页面。

在这里插入图片描述

  1. 安装过程中遇到 Logon Type 时,选择第一个。

在这里插入图片描述

  1. 端口默认为 8080,这里我填的是 8000。安装完会自动打开 http://localhost:8000 网站,这时需要等待一会,进行初始化。
  2. 按照提示找到对应的文件(直接复制路径在我的电脑中打开),其中有管理员密码。

在这里插入图片描述

  1. 安装插件,选择第一个。

在这里插入图片描述

  1. 创建管理员用户,点击完成并保存,然后一路下一步。

在这里插入图片描述

  1. 配置完成后自动进入首页,这时点击 Manage Jenkins -> Manage plugins 安装插件。

在这里插入图片描述

  1. 点击 可选插件,输入 nodejs,搜索插件,然后安装。
  2. 安装完成后回到首页,点击 Manage Jenkins -> Global Tool Configuration 配置 nodejs。如果你的电脑是 win7 的话,nodejs 版本最好不要太高,选择 v12 左右的就行。

在这里插入图片描述

创建静态服务器

  1. 建立一个空目录,在里面执行 npm init -y,初始化项目。
  2. 执行 npm i express 下载 express。
  3. 然后建立一个 server.js 文件,代码如下:
const express = require('express')
const app = express()
const port = 8080

app.use(express.static('dist'))

app.listen(port, () => {
    console.log(`Example app listening at http://localhost:${port}`)
})

它将当前目录下的 dist 文件夹设为静态服务器资源目录,然后执行 node server.js 启动服务器。

由于现在没有 dist 文件夹,所以访问网站是空页面。
在这里插入图片描述
不过不要着急,一会就能看到内容了。

自动构建 + 部署到服务器

  1. 下载 Jenkins 提供的 demo 项目 building-a-multibranch-pipeline-project,然后在你的 Gitea 新建一个仓库,把内容克隆进去,并提交到 Gitea 服务器。

在这里插入图片描述

  1. 打开 Jenkins 首页,点击 新建 Item 创建项目。

在这里插入图片描述

  1. 选择源码管理,输入你的 Gitea 上的仓库地址。

在这里插入图片描述

  1. 你也可以尝试一下定时构建,下面这个代码表示每 5 分钟构建一次。

在这里插入图片描述

  1. 选择你的构建环境,这里选择刚才配置的 nodejs。

在这里插入图片描述

  1. 点击增加构建步骤,windows 要选 execute windows batch command,linux 要选 execute shell

  1. 输入 npm i && npm run build && xcopy .\build\* G:\node-server\dist\ /s/e/y,这行命令的作用是安装依赖,构建项目,并将构建后的静态资源复制到指定目录 G:\node-server\dist\ 。这个目录是静态服务器资源目录。

在这里插入图片描述

  1. 保存后,返回首页。点击项目旁边的小三角,选择 build now

在这里插入图片描述

  1. 开始构建项目,我们可以点击项目查看构建过程。

在这里插入图片描述

  1. 构建成功,打开 http://localhost:8080/ 看一下结果。

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

  1. 由于刚才设置了每 5 分钟构建一次,我们可以改变一下网站的内容,然后什么都不做,等待一会再打开网站看看。

在这里插入图片描述

  1. 把修改的内容提交到 Gitea 服务器,稍等一会。打开网站,发现内容已经发生了变化。

在这里插入图片描述

使用 pipeline 构建项目

使用流水线构建项目可以结合 Gitea 的 webhook 钩子,以便在执行 git push 的时候,自动构建项目。

  1. 点击首页右上角的用户名,选择设置

在这里插入图片描述

  1. 添加 token,记得将 token 保存起来。

在这里插入图片描述

  1. 打开 Jenkins 首页,点击 新建 Item 创建项目。

在这里插入图片描述

  1. 点击构建触发器,选择触发远程构建,填入刚才创建的 token。

在这里插入图片描述

  1. 选择流水线,按照提示输入内容,然后点击保存

在这里插入图片描述

  1. 打开 Jenkins 安装目录下的 jenkins.xml 文件,找到 <arguments> 标签,在里面加上 -Dhudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION=true。它的作用是关闭 CSRF 验证,不关的话,Gitea 的 webhook 会一直报 403 错误,无法使用。加好参数后,在该目录命令行下输入 jenkins.exe restart 重启 Jenkins。

在这里插入图片描述

  1. 回到首页,配置全局安全选项。勾上匿名用户具有可读权限,再保存。

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

  1. 打开你的 Gitea 仓库页面,选择仓库设置

在这里插入图片描述

  1. 点击管理 web 钩子,添加 web 钩子,钩子选项选择 Gitea
  2. 目标 URL 按照 Jenkins 的提示输入内容。然后点击添加 web 钩子

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

  1. 点击创建好的 web 钩子,拉到下方,点击测试推送。不出意外,应该能看到推送成功的消息,此时回到 Jenkins 首页,发现已经在构建项目了。

在这里插入图片描述

  1. 由于没有配置 Jenkinsfile 文件,此时构建是不会成功的。所以接下来需要配置一下 Jenkinsfile 文件。将以下代码复制到你 Gitea 项目下的 Jenkinsfile 文件。jenkins 在构建时会自动读取文件的内容执行构建及部署操作。
pipeline {
    agent any
    stages {
        stage('Build') {
            steps {  // window 使用 bat, linux 使用 sh
                bat 'npm i'
                bat 'npm run build'
            }
        }
        stage('Deploy') {
            steps {
                bat 'xcopy .\\build\\* D:\\node-server\\dist\\ /s/e/y' // 这里需要改成你的静态服务器资源目录
            }
        }
    }
}
  1. 每当你的 Gitea 项目执行 push 操作时,Gitea 都会通过 webhook 发送一个 post 请求给 Jenkins,让它执行构建及部署操作。

在这里插入图片描述

小结

如果你的操作系统是 Linux,可以在 Jenkins 打包完成后,使用 ssh 远程登录到阿里云,将打包后的文件复制到阿里云上的静态服务器上,这样就能实现阿里云自动部署了。具体怎么远程登录到阿里云,请看下文中的 《Github Actions 部署到阿里云》 一节。

Github Actions 自动构建前端项目并部署到服务器

如果你的项目是 Github 项目,那么使用 Github Actions 也许是更好的选择。

部署到 Github Page

接下来看一下如何使用 Github Actions 部署到 Github Page。

在你需要部署到 Github Page 的项目下,建立一个 yml 文件,放在 .github/workflow 目录下。你可以命名为 ci.yml,它类似于 Jenkins 的 Jenkinsfile 文件,里面包含的是要自动执行的脚本代码。

这个 yml 文件的内容如下:

name: Build and Deploy
on: # 监听 master 分支上的 push 事件
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest # 构建环境使用 ubuntu
    steps:
      - name: Checkout
        uses: actions/checkout@v2.3.1  
        with:
          persist-credentials: false

      - name: Install and Build # 下载依赖 打包项目
        run: |
          npm install
          npm run build

      - name: Deploy # 将打包内容发布到 github page
        uses: JamesIves/github-pages-deploy-action@3.5.9 # 使用别人写好的 actions
        with:  # 自定义环境变量
          ACCESS_TOKEN: ${{ secrets.VUE_ADMIN_TEMPLATE }} # VUE_ADMIN_TEMPLATE 是我的 secret 名称,需要替换成你的
          BRANCH: master
          FOLDER: dist
          REPOSITORY_NAME: woai3c/woai3c.github.io # 这是我的 github page 仓库
          TARGET_FOLDER: github-actions-demo # 打包的文件将放到静态服务器 github-actions-demo 目录下

上面有一个 ACCESS_TOKEN 变量需要自己配置。

  1. 打开 Github 网站,点击你右上角的头像,选择 settings

在这里插入图片描述

  1. 点击左下角的 developer settings

在这里插入图片描述

  1. 在左侧边栏中,单击 Personal access tokens(个人访问令牌)

在这里插入图片描述

  1. 单击 Generate new token(生成新令牌)

在这里插入图片描述

  1. 输入名称并勾选 repo

在这里插入图片描述

  1. 拉到最下面,点击 Generate token,并将生成的 token 保存起来。

在这里插入图片描述

  1. 打开你的 Github 项目,点击 settings

在这里插入图片描述
点击 secrets->new secret
在这里插入图片描述
创建一个密钥,名称随便填(中间用下划线隔开),内容填入刚才创建的 token。
在这里插入图片描述

在这里插入图片描述
将上文代码中的 ACCESS_TOKEN: ${{ secrets.VUE_ADMIN_TEMPLATE }} 替换成刚才创建的 secret 名字,替换后代码如下 ACCESS_TOKEN: ${{ secrets.TEST_A_B }}。保存后,提交到 Github。

以后你的项目只要执行 git push,Github Actions 就会自动构建项目并发布到你的 Github Page 上。

Github Actions 的执行详情点击仓库中的 Actions 选项查看。

在这里插入图片描述
在这里插入图片描述
具体详情可以参考一下我的 demo 项目 github-actions-demo

构建成功后,打开 Github Page 网站,可以发现内容已经发布成功。

在这里插入图片描述

Github Actions 部署到阿里云

初始化阿里云服务器

  1. 购买阿里云服务器,选择操作系统,我选的 ubuntu
  2. 在云服务器管理控制台选择实例->更多->密钥->重置实例密码(一会登陆用)
  3. 选择远程连接->VNC,会弹出一个密码,记住它,以后远程连接要用(ctrl + alt + f1~f6 切换终端,例如 ctrl + alt + f1 是第一个终端)
  4. 进入后是一个命令行 输入 root(默认用户名),密码为你刚才重置的实例密码
  5. 登陆成功, 更新安装源 sudo apt-get update && sudo apt-get upgrade -y
  6. 安装 npm sudo apt-get install npm
  7. 安装 npm 管理包 sudo npm install -g n
  8. 安装 node 最新稳定版 sudo n stable

创建一个静态服务器

mkdir node-server // 创建 node-server 文件夹
cd node-server // 进入 node-server 文件夹
npm init -y // 初始化项目
npm i express
touch server.js // 创建 server.js 文件
vim server.js // 编辑 server.js 文件

将以下代码输入进去(用 vim 进入文件后按 i 进行编辑,保存时按 esc 然后输入 :wq,再按 enter),更多使用方法请自行搜索。

const express = require('express')
const app = express()
const port = 3388 // 填入自己的阿里云映射端口,在网络安全组配置。

app.use(express.static('dist'))

app.listen(port, '0.0.0.0', () => {
    console.log(`listening`)
})

执行 node server.js 开始监听,由于暂时没有 dist 目录,先不要着急。

注意,监听 IP 必须为 0.0.0.0 ,详情请看部署Node.js项目注意事项

阿里云入端口要在网络安全组中查看与配置。

在这里插入图片描述

创建阿里云密钥对

请参考创建SSH密钥对绑定SSH密钥对 ,将你的 ECS 服务器实例和密钥绑定,然后将私钥保存到你的电脑(例如保存在 ecs.pem 文件)。

打开你要部署到阿里云的 Github 项目,点击 setting->secrets。

在这里插入图片描述
点击 new secret
在这里插入图片描述
secret 名称为 SERVER_SSH_KEY,并将刚才的阿里云密钥填入内容。

在这里插入图片描述
点击 add secret 完成。

在你项目下建立 .github\workflows\ci.yml 文件,填入以下内容:

name: Build app and deploy to aliyun
on:
  #监听push操作
  push:
    branches:
      # master分支,你也可以改成其他分支
      - master
jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: '12.16.2'
    - name: Install npm dependencies
      run: npm install
    - name: Run build task
      run: npm run build
    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: '-rltgoDzvO --delete'
          SOURCE: dist # 这是要复制到阿里云静态服务器的文件夹名称
          REMOTE_HOST: '118.190.217.8' # 你的阿里云公网地址
          REMOTE_USER: root # 阿里云登录后默认为 root 用户,并且所在文件夹为 root
          TARGET: /root/node-server # 打包后的 dist 文件夹将放在 /root/node-server

保存,推送到 Github 上。

以后只要你的项目执行 git push 操作,就会自动执行 ci.yml 定义的脚本,将打包文件放到你的阿里云静态服务器上。

这个 Actions 主要做了两件事:

  1. 克隆你的项目,下载依赖,打包。
  2. 用你的阿里云私钥以 SSH 的方式登录到阿里云,把打包的文件上传(使用 rsync)到阿里云指定的文件夹中。

如果还是不懂,建议看一下我的 demo

ci.yml 配置文件讲解

  1. name,表示这个工作流程(workflow)的名称。
  2. on,表示监听的意思,后面可以加上各种事件,例如 push 事件。

下面这段代码表示要监听 master 分支的 push 事件。当 Github Actions 监听到 push 事件发生时,它就会执行下面 jobs 定义的一系列操作。

name: Build app and deploy to aliyun
on:
  #监听push操作
  push:
    branches:
      # master分支,你也可以改成其他分支
      - master
jobs:
...
  1. jobs,看字面意思就是一系列的作业,你可以在 jobs 字段下面定义很多作业,例如 job1job2 等等,并且它们是并行执行的。
jobs:
  job1:
      ...
  job2:
      ...
  job3:
    ...

回头看一下 ci.yml 文件,它只有一个作业,即 build,作业的名称是自己定义的,你叫 good 也可以。

  1. runs-on,表示你这个工作流程要运行在什么操作系统上,ci.yml 文件定义的是最新稳定版的 ubuntu。除了 ubuntu,它还可以选择 Mac 或 Windows。

  1. steps,看字面意思就是一系列的步骤,也就是说这个作业由一系列的步骤完成。例如先执行 step1,再执行 step2...

setps 步骤讲解

setps 其实是一个数组,在 YAML 语法中,以 - 开始就是一个数组项。例如 ['a', 'b', 'c'] 用 YAML 语法表示为:

- a
- b
- c

所以 setps 就是一个步骤数组,从上到下开始执行。从 ci.yml 文件来看,每一个小步骤都有几个相关选项:

  1. name,小步骤的名称。
  2. uses,小步骤使用的 actions 库名称或路径,Github Actions 允许你使用别人写好的 Actions 库。
  3. run,小步骤要执行的 shell 命令。
  4. env,设置与小步骤相关的环境变量。
  5. with,提供参数。

综上所述,ci.yml 文件中的 setps 就很好理解了,下面从头到尾解释一边:

    steps:
    - uses: actions/checkout@v1
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: '12.16.2'
    - name: Install npm dependencies
      run: npm install
    - name: Run build task
      run: npm run build
    - name: Deploy to Server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
          SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }}
          ARGS: '-rltgoDzvO --delete'
          SOURCE: dist # 这是要复制到阿里云静态服务器的文件夹名称
          REMOTE_HOST: '118.190.217.8' # 你的阿里云公网地址
          REMOTE_USER: root # 阿里云登录后默认为 root 用户,并且所在文件夹为 root
          TARGET: /root/node-server # 打包后的 dist 文件夹将放在 /root/node-server
  1. 使用 actions/checkout@v1 库克隆代码到 ubuntu 上。
  2. 使用 actions/setup-node@v1 库安装 nodejs,with 提供了一个参数 node-version 表示要安装的 nodejs 版本。
  3. ubuntushell 上执行 npm install 下载依赖。
  4. 执行 npm run build 打包项目。
  5. 使用 easingthemes/ssh-deploy@v2.1.5 库,这个库的作用就是用 SSH 的方式远程登录到阿里云服务器,将打包好的文件夹复制到阿里云指定的目录上。

env 上可以看到,这个 actions 库要求我们提供几个环境变量:

  1. SSH_PRIVATE_KEY: 阿里云密钥对中的私钥(需要你提前写在 github secrets 上),
  2. ARGS: '-rltgoDzvO --delete',没仔细研究,我猜是复制完文件就删除掉。
  3. SOURCE:打包后的文件夹名称
  4. REMOTE_HOST: 阿里云公网 IP 地址
  5. REMOTE_USER: 阿里云服务器的用户名
  6. TARGET: 你要拷贝到阿里云服务器指定目录的名称

如果你想了解一下其他 actions 库的实现,可以直接复制 actions 库的名称去搜索引擎搜索一下,例如搜索 actions/checkout 的结果为:

都看到这了,给个赞再走吧。

参考资料

更多文章,敬请关注

查看原文

赞 89 收藏 74 评论 6

蓝色的秋风 赞了文章 · 11月10日

前端性能优化 24 条建议(2020)

性能优化是把双刃剑,有好的一面也有坏的一面。好的一面就是能提升网站性能,坏的一面就是配置麻烦,或者要遵守的规则太多。并且某些性能优化规则并不适用所有场景,需要谨慎使用,请读者带着批判性的眼光来阅读本文。

本文相关的优化建议的引用资料出处均会在建议后面给出,或者放在文末。

1. 减少 HTTP 请求

一个完整的 HTTP 请求需要经历 DNS 查找,TCP 握手,浏览器发出 HTTP 请求,服务器接收请求,服务器处理请求并发回响应,浏览器接收响应等过程。接下来看一个具体的例子帮助理解 HTTP :

在这里插入图片描述

这是一个 HTTP 请求,请求的文件大小为 28.4KB。

名词解释:

  • Queueing: 在请求队列中的时间。
  • Stalled: 从TCP 连接建立完成,到真正可以传输数据之间的时间差,此时间包括代理协商时间。
  • Proxy negotiation: 与代理服务器连接进行协商所花费的时间。
  • DNS Lookup: 执行DNS查找所花费的时间,页面上的每个不同的域都需要进行DNS查找。
  • Initial Connection / Connecting: 建立连接所花费的时间,包括TCP握手/重试和协商SSL。
  • SSL: 完成SSL握手所花费的时间。
  • Request sent: 发出网络请求所花费的时间,通常为一毫秒的时间。
  • Waiting(TFFB): TFFB 是发出页面请求到接收到应答数据第一个字节的时间总和,它包含了 DNS 解析时间、 TCP 连接时间、发送 HTTP 请求时间和获得响应消息第一个字节的时间。
  • Content Download: 接收响应数据所花费的时间。

从这个例子可以看出,真正下载数据的时间占比为 13.05 / 204.16 = 6.39%,文件越小,这个比例越小,文件越大,比例就越高。这就是为什么要建议将多个小文件合并为一个大文件,从而减少 HTTP 请求次数的原因。

参考资料:

2. 使用 HTTP2

HTTP2 相比 HTTP1.1 有如下几个优点:

解析速度快

服务器解析 HTTP1.1 的请求时,必须不断地读入字节,直到遇到分隔符 CRLF 为止。而解析 HTTP2 的请求就不用这么麻烦,因为 HTTP2 是基于帧的协议,每个帧都有表示帧长度的字段。

多路复用

HTTP1.1 如果要同时发起多个请求,就得建立多个 TCP 连接,因为一个 TCP 连接同时只能处理一个 HTTP1.1 的请求。

在 HTTP2 上,多个请求可以共用一个 TCP 连接,这称为多路复用。同一个请求和响应用一个流来表示,并有唯一的流 ID 来标识。
多个请求和响应在 TCP 连接中可以乱序发送,到达目的地后再通过流 ID 重新组建。

首部压缩

HTTP2 提供了首部压缩功能。

例如有如下两个请求:

:authority: unpkg.zhimg.com
:method: GET
:path: /za-js-sdk@2.16.0/dist/zap.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
:authority: zz.bdstatic.com
:method: GET
:path: /linksubmit/push.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36

从上面两个请求可以看出来,有很多数据都是重复的。如果可以把相同的首部存储起来,仅发送它们之间不同的部分,就可以节省不少的流量,加快请求的时间。

HTTP/2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送。

下面再来看一个简化的例子,假设客户端按顺序发送如下请求首部:

Header1:foo
Header2:bar
Header3:bat

当客户端发送请求时,它会根据首部值创建一张表:

索引首部名称
62Header1foo
63Header2bar
64Header3bat

如果服务器收到了请求,它会照样创建一张表。
当客户端发送下一个请求的时候,如果首部相同,它可以直接发送这样的首部块:

62 63 64

服务器会查找先前建立的表格,并把这些数字还原成索引对应的完整首部。

优先级

HTTP2 可以对比较紧急的请求设置一个较高的优先级,服务器在收到这样的请求后,可以优先处理。

流量控制

由于一个 TCP 连接流量带宽(根据客户端到服务器的网络带宽而定)是固定的,当有多个请求并发时,一个请求占的流量多,另一个请求占的流量就会少。流量控制可以对不同的流的流量进行精确控制。

服务器推送

HTTP2 新增的一个强大的新功能,就是服务器可以对一个客户端请求发送多个响应。换句话说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。

例如当浏览器请求一个网站时,除了返回 HTML 页面外,服务器还可以根据 HTML 页面中的资源的 URL,来提前推送资源。

现在有很多网站已经开始使用 HTTP2 了,例如知乎:

在这里插入图片描述

其中 h2 是指 HTTP2 协议,http/1.1 则是指 HTTP1.1 协议。

参考资料:

3. 使用服务端渲染

客户端渲染: 获取 HTML 文件,根据需要下载 JavaScript 文件,运行文件,生成 DOM,再渲染。

服务端渲染:服务端返回 HTML 文件,客户端只需解析 HTML。

  • 优点:首屏渲染快,SEO 好。
  • 缺点:配置麻烦,增加了服务器的计算压力。

参考资料:

4. 静态资源使用 CDN

内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。

CDN 原理

当用户访问一个网站时,如果没有 CDN,过程是这样的:

  1. 浏览器要将域名解析为 IP 地址,所以需要向本地 DNS 发出请求。
  2. 本地 DNS 依次向根服务器、顶级域名服务器、权限服务器发出请求,得到网站服务器的 IP 地址。
  3. 本地 DNS 将 IP 地址发回给浏览器,浏览器向网站服务器 IP 地址发出请求并得到资源。

如果用户访问的网站部署了 CDN,过程是这样的:

  1. 浏览器要将域名解析为 IP 地址,所以需要向本地 DNS 发出请求。
  2. 本地 DNS 依次向根服务器、顶级域名服务器、权限服务器发出请求,得到全局负载均衡系统(GSLB)的 IP 地址。
  3. 本地 DNS 再向 GSLB 发出请求,GSLB 的主要功能是根据本地 DNS 的 IP 地址判断用户的位置,筛选出距离用户较近的本地负载均衡系统(SLB),并将该 SLB 的 IP 地址作为结果返回给本地 DNS。
  4. 本地 DNS 将 SLB 的 IP 地址发回给浏览器,浏览器向 SLB 发出请求。
  5. SLB 根据浏览器请求的资源和地址,选出最优的缓存服务器发回给浏览器。
  6. 浏览器再根据 SLB 发回的地址重定向到缓存服务器。
  7. 如果缓存服务器有浏览器需要的资源,就将资源发回给浏览器。如果没有,就向源服务器请求资源,再发给浏览器并缓存在本地。

参考资料:

5. 将 CSS 放在文件头部,JavaScript 文件放在底部

所有放在 head 标签里的 CSS 和 JS 文件都会堵塞渲染。如果这些 CSS 和 JS 需要加载和解析很久的话,那么页面就空白了。所以 JS 文件要放在底部,等 HTML 解析完了再加载 JS 文件。

那为什么 CSS 文件还要放在头部呢?

因为先加载 HTML 再加载 CSS,会让用户第一时间看到的页面是没有样式的、“丑陋”的,为了避免这种情况发生,就要将 CSS 文件放在头部了。

另外,JS 文件也不是不可以放在头部,只要给 script 标签加上 defer 属性就可以了,异步下载,延迟执行。

6. 使用字体图标 iconfont 代替图片图标

字体图标就是将图标制作成一个字体,使用时就跟字体一样,可以设置属性,例如 font-size、color 等等,非常方便。并且字体图标是矢量图,不会失真。还有一个优点是生成的文件特别小。

压缩字体文件

使用 fontmin-webpack 插件对字体文件进行压缩(感谢前端小伟提供)。

参考资料:

7. 善用缓存,不重复加载相同的资源

为了避免用户每次访问网站都得请求文件,我们可以通过添加 Expires 或 max-age 来控制这一行为。Expires 设置了一个时间,只要在这个时间之前,浏览器都不会请求文件,而是直接使用缓存。而 max-age 是一个相对时间,建议使用 max-age 代替 Expires 。

不过这样会产生一个问题,当文件更新了怎么办?怎么通知浏览器重新请求文件?

可以通过更新页面中引用的资源链接地址,让浏览器主动放弃缓存,加载新资源。

具体做法是把资源地址 URL 的修改与文件内容关联起来,也就是说,只有文件内容变化,才会导致相应 URL 的变更,从而实现文件级别的精确缓存控制。什么东西与文件内容相关呢?我们会很自然的联想到利用数据摘要要算法对文件求摘要信息,摘要信息与文件内容一一对应,就有了一种可以精确到单个文件粒度的缓存控制依据了。

参考资料:

8. 压缩文件

压缩文件可以减少文件下载时间,让用户体验性更好。

得益于 webpack 和 node 的发展,现在压缩文件已经非常方便了。

在 webpack 可以使用如下插件进行压缩:

  • JavaScript:UglifyPlugin
  • CSS :MiniCssExtractPlugin
  • HTML:HtmlWebpackPlugin

其实,我们还可以做得更好。那就是使用 gzip 压缩。可以通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。当然,服务器也得支持这一功能。

gzip 是目前最流行和最有效的压缩方法。举个例子,我用 Vue 开发的项目构建后生成的 app.js 文件大小为 1.4MB,使用 gzip 压缩后只有 573KB,体积减少了将近 60%。

附上 webpack 和 node 配置 gzip 的使用方法。

下载插件

npm install compression-webpack-plugin --save-dev
npm install compression

webpack 配置

const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [new CompressionPlugin()],
}

node 配置

const compression = require('compression')
// 在其他中间件前使用
app.use(compression())

9. 图片优化

(1). 图片延迟加载

在页面中,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片,这就是延迟加载。对于图片很多的网站来说,一次性加载全部图片,会对用户体验造成很大的影响,所以需要使用图片延迟加载。

首先可以将图片这样设置,在页面不可见时图片不会加载:

<img data-data-original="https://avatars0.githubusercontent.com/u/22117876?s=460&u=7bd8f32788df6988833da6bd155c3cfbebc68006&v=4">

等页面可见时,使用 JS 加载图片:

const img = document.querySelector('img')
img.src = img.dataset.src

这样图片就加载出来了,完整的代码可以看一下参考资料。

参考资料:

(2). 响应式图片

响应式图片的优点是浏览器能够根据屏幕大小自动加载合适的图片。

通过 picture 实现

<picture>
    <source srcset="banner_w1000.jpg" media="(min-width: 801px)">
    <source srcset="banner_w800.jpg" media="(max-width: 800px)">
    <img data-original="banner_w800.jpg" alt="">
</picture>

通过 @media 实现

@media (min-width: 769px) {
    .bg {
        background-image: url(bg1080.jpg);
    }
}
@media (max-width: 768px) {
    .bg {
        background-image: url(bg768.jpg);
    }
}

(3). 调整图片大小

例如,你有一个 1920 * 1080 大小的图片,用缩略图的方式展示给用户,并且当用户鼠标悬停在上面时才展示全图。如果用户从未真正将鼠标悬停在缩略图上,则浪费了下载图片的时间。

所以,我们可以用两张图片来实行优化。一开始,只加载缩略图,当用户悬停在图片上时,才加载大图。还有一种办法,即对大图进行延迟加载,在所有元素都加载完成后手动更改大图的 src 进行下载。

(4). 降低图片质量

例如 JPG 格式的图片,100% 的质量和 90% 质量的通常看不出来区别,尤其是用来当背景图的时候。我经常用 PS 切背景图时, 将图片切成 JPG 格式,并且将它压缩到 60% 的质量,基本上看不出来区别。

压缩方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。

以下附上 webpack 插件 image-webpack-loader 的用法。

npm i -D image-webpack-loader

webpack 配置

{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000, /* 图片大小小于1000字节限制时会自动转成 base64 码引用*/
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    /*对图片进行压缩*/
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

(5). 尽可能利用 CSS3 效果代替图片

有很多图片使用 CSS 效果(渐变、阴影等)就能画出来,这种情况选择 CSS3 效果更好。因为代码大小通常是图片大小的几分之一甚至几十分之一。

参考资料:

(6). 使用 webp 格式的图片

WebP 的优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当优秀、稳定和统一。

参考资料:

10. 通过 webpack 按需加载代码,提取第三库代码,减少 ES6 转为 ES5 的冗余代码

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

根据文件内容生成文件名,结合 import 动态引入组件实现按需加载

通过配置 output 的 filename 属性可以实现这个需求。filename 属性的值选项中有一个 [contenthash],它将根据文件内容创建出唯一 hash。当文件内容发生变化时,[contenthash] 也会发生变化。

output: {
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js',
    path: path.resolve(__dirname, '../dist'),
},

提取第三方库

由于引入的第三方库一般都比较稳定,不会经常改变。所以将它们单独提取出来,作为长期缓存是一个更好的选择。
这里需要使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

optimization: {
      runtimeChunk: {
        name: 'manifest' // 将 webpack 的 runtime 代码拆分为一个单独的 chunk。
    },
    splitChunks: {
        cacheGroups: {
            vendor: {
                name: 'chunk-vendors',
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                chunks: 'initial'
            },
            common: {
                name: 'chunk-common',
                minChunks: 2,
                priority: -20,
                chunks: 'initial',
                reuseExistingChunk: true
            }
        },
    }
},
  • test: 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function;
  • priority:表示抽取权重,数字越大表示优先级越高。因为一个 module 可能会满足多个 cacheGroups 的条件,那么抽取到哪个就由权重最高的说了算;
  • reuseExistingChunk:表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。
  • minChunks(默认是1):在分割之前,这个代码块最小应该被引用的次数(译注:保证代码块复用性,默认配置的策略是不需要多次引用也可以被分割)
  • chunks (默认是async) :initial、async和all
  • name(打包的chunks的名字):字符串或者函数(函数可以根据条件自定义名字)

减少 ES6 转为 ES5 的冗余代码

Babel 转化后的代码想要实现和原来代码一样的功能需要借助一些帮助函数,比如:

class Person {}

会被转换为:

"use strict";

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

var Person = function Person() {
  _classCallCheck(this, Person);
};

这里 _classCallCheck 就是一个 helper 函数,如果在很多文件里都声明了类,那么就会产生很多个这样的 helper 函数。

这里的 @babel/runtime 包就声明了所有需要用到的帮助函数,而 @babel/plugin-transform-runtime 的作用就是将所有需要 helper 函数的文件,从 @babel/runtime包 引进来:

"use strict";

var _classCallCheck2 = require("@babel/runtime/helpers/classCallCheck");

var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);

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

var Person = function Person() {
  (0, _classCallCheck3.default)(this, Person);
};

这里就没有再编译出 helper 函数 classCallCheck 了,而是直接引用了 @babel/runtime 中的 helpers/classCallCheck

安装

npm i -D @babel/plugin-transform-runtime @babel/runtime

使用
.babelrc 文件中

"plugins": [
        "@babel/plugin-transform-runtime"
]

参考资料:

11. 减少重绘重排

浏览器渲染过程

  1. 解析HTML生成DOM树。
  2. 解析CSS生成CSSOM规则树。
  3. 将DOM树与CSSOM规则树合并在一起生成渲染树。
  4. 遍历渲染树开始布局,计算每个节点的位置大小信息。
  5. 将渲染树每个节点绘制到屏幕。

在这里插入图片描述

重排

当改变 DOM 元素位置或大小时,会导致浏览器重新生成渲染树,这个过程叫重排。

重绘

当重新生成渲染树后,就要将渲染树每个节点绘制到屏幕,这个过程叫重绘。不是所有的动作都会导致重排,例如改变字体颜色,只会导致重绘。记住,重排会导致重绘,重绘不会导致重排 。

重排和重绘这两个操作都是非常昂贵的,因为 JavaScript 引擎线程与 GUI 渲染线程是互斥,它们同时只能一个在工作。

什么操作会导致重排?

  • 添加或删除可见的 DOM 元素
  • 元素位置改变
  • 元素尺寸改变
  • 内容改变
  • 浏览器窗口尺寸改变

如何减少重排重绘?

  • 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。
  • 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。

12. 使用事件委托

事件委托利用了事件冒泡,只指定一个事件处理程序,就可以管理某一类型的所有事件。所有用到按钮的事件(多数鼠标事件和键盘事件)都适合采用事件委托技术, 使用事件委托可以节省内存。

<ul>
  <li>苹果</li>
  <li>香蕉</li>
  <li>凤梨</li>
</ul>

// good
document.querySelector('ul').onclick = (event) => {
  const target = event.target
  if (target.nodeName === 'LI') {
    console.log(target.innerHTML)
  }
}

// bad
document.querySelectorAll('li').forEach((e) => {
  e.onclick = function() {
    console.log(this.innerHTML)
  }
}) 

13. 注意程序的局部性

一个编写良好的计算机程序常常具有良好的局部性,它们倾向于引用最近引用过的数据项附近的数据项,或者最近引用过的数据项本身,这种倾向性,被称为局部性原理。有良好局部性的程序比局部性差的程序运行得更快。

局部性通常有两种不同的形式:

  • 时间局部性:在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来被多次引用。
  • 空间局部性 :在一个具有良好空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。

时间局部性示例

function sum(arry) {
    let i, sum = 0
    let len = arry.length

    for (i = 0; i < len; i++) {
        sum += arry[i]
    }

    return sum
}

在这个例子中,变量sum在每次循环迭代中被引用一次,因此,对于sum来说,具有良好的时间局部性

空间局部性示例

具有良好空间局部性的程序

// 二维数组 
function sum1(arry, rows, cols) {
    let i, j, sum = 0

    for (i = 0; i < rows; i++) {
        for (j = 0; j < cols; j++) {
            sum += arry[i][j]
        }
    }
    return sum
}

空间局部性差的程序

// 二维数组 
function sum2(arry, rows, cols) {
    let i, j, sum = 0

    for (j = 0; j < cols; j++) {
        for (i = 0; i < rows; i++) {
            sum += arry[i][j]
        }
    }
    return sum
}

看一下上面的两个空间局部性示例,像示例中从每行开始按顺序访问数组每个元素的方式,称为具有步长为1的引用模式。
如果在数组中,每隔k个元素进行访问,就称为步长为k的引用模式。
一般而言,随着步长的增加,空间局部性下降。

这两个例子有什么区别?区别在于第一个示例是按行扫描数组,每扫描完一行再去扫下一行;第二个示例是按列来扫描数组,扫完一行中的一个元素,马上就去扫下一行中的同一列元素。

数组在内存中是按照行顺序来存放的,结果就是逐行扫描数组的示例得到了步长为 1 引用模式,具有良好的空间局部性;而另一个示例步长为 rows,空间局部性极差。

性能测试

运行环境:

  • cpu: i5-7400
  • 浏览器: chrome 70.0.3538.110

对一个长度为9000的二维数组(子数组长度也为9000)进行10次空间局部性测试,时间(毫秒)取平均值,结果如下:

所用示例为上述两个空间局部性示例

步长为 1步长为 9000
1242316

从以上测试结果来看,步长为 1 的数组执行时间比步长为 9000 的数组快了一个数量级。

总结:

  • 重复引用相同变量的程序具有良好的时间局部性
  • 对于具有步长为 k 的引用模式的程序,步长越小,空间局部性越好;而在内存中以大步长跳来跳去的程序空间局部性会很差

参考资料:

14. if-else 对比 switch

当判断条件数量越来越多时,越倾向于使用 switch 而不是 if-else。

if (color == 'blue') {

} else if (color == 'yellow') {

} else if (color == 'white') {

} else if (color == 'black') {

} else if (color == 'green') {

} else if (color == 'orange') {

} else if (color == 'pink') {

}

switch (color) {
    case 'blue':

        break
    case 'yellow':

        break
    case 'white':

        break
    case 'black':

        break
    case 'green':

        break
    case 'orange':

        break
    case 'pink':

        break
}

像以上这种情况,使用 switch 是最好的。假设 color 的值为 pink,则 if-else 语句要进行 7 次判断,switch 只需要进行一次判断。 从可读性来说,switch 语句也更好。

从使用时机来说,当条件值大于两个的时候,使用 switch 更好。不过 if-else 也有 switch 无法做到的事情,例如有多个判断条件的情况下,无法使用 switch。

15. 查找表

当条件语句特别多时,使用 switch 和 if-else 不是最佳的选择,这时不妨试一下查找表。查找表可以使用数组和对象来构建。

switch (index) {
    case '0':
        return result0
    case '1':
        return result1
    case '2':
        return result2
    case '3':
        return result3
    case '4':
        return result4
    case '5':
        return result5
    case '6':
        return result6
    case '7':
        return result7
    case '8':
        return result8
    case '9':
        return result9
    case '10':
        return result10
    case '11':
        return result11
}

可以将这个 switch 语句转换为查找表

const results = [result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10,result11]

return results[index]

如果条件语句不是数值而是字符串,可以用对象来建立查找表

const map = {
  red: result0,
  green: result1,
}

return map[color]

16. 避免页面卡顿

60fps 与设备刷新率

目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果在页面中有一个动画或渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。
其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。

在这里插入图片描述

假如你用 JavaScript 修改了 DOM,并触发样式修改,经历重排重绘最后画到屏幕上。如果这其中任意一项的执行时间过长,都会导致渲染这一帧的时间过长,平均帧率就会下降。假设这一帧花了 50 ms,那么此时的帧率为 1s / 50ms = 20fps,页面看起来就像卡顿了一样。

对于一些长时间运行的 JavaScript,我们可以使用定时器进行切分,延迟执行。

for (let i = 0, len = arry.length; i < len; i++) {
    process(arry[i])
}

假设上面的循环结构由于 process() 复杂度过高或数组元素太多,甚至两者都有,可以尝试一下切分。

const todo = arry.concat()
setTimeout(function() {
    process(todo.shift())
    if (todo.length) {
        setTimeout(arguments.callee, 25)
    } else {
        callback(arry)
    }
}, 25)

如果有兴趣了解更多,可以查看一下高性能JavaScript第 6 章和高效前端:Web高效编程与优化实践第 3 章。

参考资料:

17. 使用 requestAnimationFrame 来实现视觉变化

从第 16 点我们可以知道,大多数设备屏幕刷新率为 60 次/秒,也就是说每一帧的平均时间为 16.66 毫秒。在使用 JavaScript 实现动画效果的时候,最好的情况就是每次代码都是在帧的开头开始执行。而保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame

/**
 * If run as a requestAnimationFrame callback, this
 * will be run at the start of the frame.
 */
function updateScreen(time) {
  // Make visual updates here.
}

requestAnimationFrame(updateScreen);

如果采取 setTimeoutsetInterval 来实现动画的话,回调函数将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。

在这里插入图片描述

参考资料:

18. 使用 Web Workers

Web Worker 使用其他工作线程从而独立于主线程之外,它可以执行任务而不干扰用户界面。一个 worker 可以将消息发送到创建它的 JavaScript 代码, 通过将消息发送到该代码指定的事件处理程序(反之亦然)。

Web Worker 适用于那些处理纯数据,或者与浏览器 UI 无关的长时间运行脚本。

创建一个新的 worker 很简单,指定一个脚本的 URI 来执行 worker 线程(main.js):

var myWorker = new Worker('worker.js');
// 你可以通过postMessage() 方法和onmessage事件向worker发送消息。
first.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

second.onchange = function() {
  myWorker.postMessage([first.value,second.value]);
  console.log('Message posted to worker');
}

在 worker 中接收到消息后,我们可以写一个事件处理函数代码作为响应(worker.js):

onmessage = function(e) {
  console.log('Message received from main script');
  var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
  console.log('Posting message back to main script');
  postMessage(workerResult);
}

onmessage处理函数在接收到消息后马上执行,代码中消息本身作为事件的data属性进行使用。这里我们简单的对这2个数字作乘法处理并再次使用postMessage()方法,将结果回传给主线程。

回到主线程,我们再次使用onmessage以响应worker回传的消息:

myWorker.onmessage = function(e) {
  result.textContent = e.data;
  console.log('Message received from worker');
}

在这里我们获取消息事件的data,并且将它设置为result的textContent,所以用户可以直接看到运算的结果。

不过在worker内,不能直接操作DOM节点,也不能使用window对象的默认方法和属性。然而你可以使用大量window对象之下的东西,包括WebSockets,IndexedDB以及FireFox OS专用的Data Store API等数据存储机制。

参考资料:

19. 使用位操作

JavaScript 中的数字都使用 IEEE-754 标准以 64 位格式存储。但是在位操作中,数字被转换为有符号的 32 位格式。即使需要转换,位操作也比其他数学运算和布尔操作快得多。

取模

由于偶数的最低位为 0,奇数为 1,所以取模运算可以用位操作来代替。

if (value % 2) {
    // 奇数
} else {
    // 偶数 
}
// 位操作
if (value & 1) {
    // 奇数
} else {
    // 偶数
}
取整
~~10.12 // 10
~~10 // 10
~~'1.5' // 1
~~undefined // 0
~~null // 0
位掩码
const a = 1
const b = 2
const c = 4
const options = a | b | c

通过定义这些选项,可以用按位与操作来判断 a/b/c 是否在 options 中。

// 选项 b 是否在选项中
if (b & options) {
    ...
}

20. 不要覆盖原生方法

无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。

21. 降低 CSS 选择器的复杂性

(1). 浏览器读取选择器,遵循的原则是从选择器的右边到左边读取。

看个示例

#block .text p {
    color: red;
}
  1. 查找所有 P 元素。
  2. 查找结果 1 中的元素是否有类名为 text 的父元素
  3. 查找结果 2 中的元素是否有 id 为 block 的父元素

(2). CSS 选择器优先级

内联 > ID选择器 > 类选择器 > 标签选择器

根据以上两个信息可以得出结论。

  1. 选择器越短越好。
  2. 尽量使用高优先级的选择器,例如 ID 和类选择器。
  3. 避免使用通配符 *。

最后要说一句,据我查找的资料所得,CSS 选择器没有优化的必要,因为最慢和慢快的选择器性能差别非常小。

参考资料:

22. 使用 flexbox 而不是较早的布局模型

在早期的 CSS 布局方式中我们能对元素实行绝对定位、相对定位或浮动定位。而现在,我们有了新的布局方式 flexbox,它比起早期的布局方式来说有个优势,那就是性能比较好。

下面的截图显示了在 1300 个框上使用浮动的布局开销:

在这里插入图片描述

然后我们用 flexbox 来重现这个例子:

在这里插入图片描述

现在,对于相同数量的元素和相同的视觉外观,布局的时间要少得多(本例中为分别 3.5 毫秒和 14 毫秒)。

不过 flexbox 兼容性还是有点问题,不是所有浏览器都支持它,所以要谨慎使用。

各浏览器兼容性:

  • Chrome 29+
  • Firefox 28+
  • Internet Explorer 11
  • Opera 17+
  • Safari 6.1+ (prefixed with -webkit-)
  • Android 4.4+
  • iOS 7.1+ (prefixed with -webkit-)

参考资料:

23. 使用 transform 和 opacity 属性更改来实现动画

在 CSS 中,transforms 和 opacity 这两个属性更改不会触发重排与重绘,它们是可以由合成器(composite)单独处理的属性。

在这里插入图片描述

参考资料:

24. 合理使用规则,避免过度优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

上述 23 条建议中,属于加载时优化的是前面 10 条建议,属于运行时优化的是后面 13 条建议。通常来说,没有必要 23 条性能优化规则都用上,根据网站用户群体来做针对性的调整是最好的,节省精力,节省时间。

在解决问题之前,得先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

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

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

window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。performance 的具体使用方法请用搜索引擎搜索一下,毕竟篇幅有限。

通过检查加载和运行性能,相信你对网站性能已经有了大概了解。所以这时候要做的事情,就是使用上述 23 条建议尽情地去优化你的网站,加油!

参考资料:

其他参考资料

更多文章,欢迎关注

查看原文

赞 204 收藏 150 评论 6

蓝色的秋风 赞了文章 · 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. 推送代码 git push
  3. 构建项目 npm run build
  4. 将打包好的文件放到静态服务器。

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

自动部署(又叫持续部署 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》这本书。

参考资料:

总结

写这篇文章主要是为了对我这一年多工作经验作总结,因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手,通过这篇文章入门前端工程化。

如果这篇文章对你有帮助,请点一下赞,感激不尽。

查看原文

赞 124 收藏 80 评论 21

蓝色的秋风 发布了文章 · 11月5日

从王者荣耀里我学会的前端新手指引

微信搜索「秋风的笔记」。关注我,前端学习不迷路。

在王者的世界里,不仅仅只有快乐,还能搞学习,让你成为快乐而又富有知识的人。这其中的功臣,这不得不说的就是它的新手指引。

为什么这么说呢?我们先来看几张图。

WechatIMG11731

没错,上面的就是王者荣耀的新手引导,手把手教学,还有妲己美妙的声音。

整个过程大约 2分钟。它使用了多种引导方式,蒙层引导、气泡引导、视频引导、操作引导还有预设任务,可以说在新手引导方面,真的很"细",用了各种各样的花样。但是它用了仅仅 2 分钟的指引就让你快速体验到了整个产品的使用方式,让你感受到打败敌人是如此满足,赢得一场游戏是如此简单。你收获了大量的快乐同时产生对它的依赖。

而如果说,没有新手指引,让一个没有从来没有玩过此类游戏的新人,就上手一个 5v5 的战斗,在自己还没弄懂操作,就上手实战,那么新人肯定会被打的很惨,受到队友的抱怨不说,很快会输掉一场比赛,从而产生挫败感,觉得这个游戏垃圾,更不用说从游戏中体验快感。

WechatIMG11761

所以说新手引导是一种能让用户在短时间内快速了解产品特色以及产品使用方式。

它是非常重要也是非常有必要学习的一个功能!这也是本篇文章想要介绍的重点内容。下面就进行原理实战讲解

我先介绍一下常见的几种类型新手引导效果图。

1.引导页

引导页一般出现在首次打开APP的时候,由3-5个页面组成。

8种引导方式,7个设计要点,让你全面了解新手引导!

2.蒙层引导

在产品的整个界面上方用一个黑色半透明蒙层进行遮罩,这种引导方式可以让用户聚焦了解被圈注的功能点或手势说明。

image-20201103222848321

3.气泡/弹窗提示

在操作按钮旁边弹出一个气泡提示框或者直接弹出弹窗。

image-20201103222946696

4.动画/视频引导

用户可以根据动态演示,很快地理解整个产品。

image-20201103222839640

5.操作式引导

一步一步地引导你进行操作,鼓励用户参与其中,边学边用。

image-20201103222855636

6.预设任务

预设任务是指在用户进入产品后,自动为用户创建了一些和产品形态相关的示例,而不是留给用户一个空页面。

image-20201103225508773

人将降大任于斯人也,所以最近我就遇到了这样的一个需求。不过我需要实现的也比较简单,只需要实现蒙层引导。

今天我们就来实现一下这个功能。先来看一下我们目标的样子。核心代码大概只需要花 5 分钟就能学会,只需 9 行 js 代码,60 行 css 代码。所以接着往下看吧 ~ 高亮部分会有不一样的收获哦 ~

image-20201102235430928

主要包括三个部分: 蒙层、气泡、高亮。

image-20201103223327737蒙层和气泡对于很多同学来说,真的是太熟悉了。这里就只贴代码了,没有什么过多的可以讲解,主要是利用了绝对定位。

// 蒙层实现
<style> .guide-mask {
  z-index: 999999;
  background-color: #000;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  position: fixed;
  opacity: 0.8;
} </style>
<div class="guide-mask"></div> 
// 气泡实现
<style> .tooltip-box:before {
  content: "";
  position: absolute;
  right: 100%;
  top: -10px;
  left: 20%;
  width: 0;
  height: 0;
  border-left: 10px solid transparent;
  border-right: 10px solid transparent;
  border-bottom: 13px solid white;
} </style>
<div class='tooltip-box'>
  秋风的技能
</div> 

图层拼接

而这个高亮怎么实现呢?如何能让蒙层中间产生一个空白框呢?在我所知的 CSS 属性中并没有相关的属性可以实现这个特性,如果不能这样实现。那意味着我是不是需要自己将这个高亮区块给空出来呢,自己通过拼接的方式来实现。如下所示,这是我第一直觉想到的方案。

image-20201103222804567

这一种方法比较傻瓜式,但是就是比较繁琐。

z-index

z-index 属性设定了一个定位元素及其后代元素或 flex 项目的 z-order。 当元素之间重叠的时候, z-index 较大的元素会覆盖较小的元素在上层进行显示。

image-20201104225256952

因为我们可以利用 z-index 这个特性,只要将我们目标元素的 z-index 设置成比我们的蒙层高就行。

image-20201103224007763

1604418064799

通过图层分解,我们可以看到,目标的元素那一行秋风的技能是处于最高层,而不是和 秋风的笔记文字处于同一层。因此采取的方案是,我们没办法让蒙层在中间空出来,但是,我们可以通过z-index让我们的目标元素置于蒙层之上,然后再在蒙层和目标元素之间加入一个白色的背景框,这样就达到了高亮的效果。如果还看不明白可以看下图。

image-20201104230122314

有了以上的知识就差定位了,我们通过 getBoundingClientRect 属性来获取目标元素的大小及其相对于视口的位置。然后通过绝对定位来进行布局。以下就是这个实现的主要逻辑(代码比较粗糙,主要是意思表达

<style> ...
  .guide-helper-layer {
    position: absolute;
    z-index: 9999998;
    background-color: #FFF;
    background-color: rgba(255, 255, 255, .9);
    border-radius: 4px;
  }
  .guide-content {
    position: absolute;
    z-index: 10000000;
    background-color: transparent;
  }
  .guide-mark-relative {
    position: relative;
    z-index: 9999999 !important;
  }
  ... </style>
</head>
<body>
    <h2>秋风的笔记</h2>
    <div class="skill guide-mark-relative">
        ...
    </div>
    <div class="guide-mask"></div>
    <div class="guide-helper-layer" style="width: 472px; height:58px; top:55px;left: 36px;">
        <div class='tooltip-box'>
            秋风的技能
        </div>
    </div>
    <script> const guideTarget = document.querySelector('.skill')
        const tooltip = document.querySelector('.tooltip-box')
        var rect = guideTarget.getBoundingClientRect()
        const helperLayer = document.querySelector('.guide-helper-layer')
        helperLayer.style.left = rect.left - 3 + 'px'
        helperLayer.style.top = rect.top - 3 + 'px'
        helperLayer.style.width = rect.width + 3 * 2 + 'px'
        helperLayer.style.height = rect.height + 3 * 2 + 'px'
        tooltip.style.top = rect.height + 3 * 2 + 10 + 5 + 'px' </script> 

以上就是实现一个蒙层引导的实现方案。当然这么精妙的设计也是离不开伟大的开源项目,以上就是参考了开源项目 introjs 来实现的。

box-shadow

image-20201104210129919

这个方案除了兼容性问题(不兼容低版本ie8以及以前版本),也是比较简单的一个方法。来看看 box-shadow 的方法介绍。

`/* x偏移量 | y偏移量 | 阴影模糊半径 | 阴影扩散半径 | 阴影颜色 */
box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2);` 

核心思路为我们可以通过设置一个比较大的阴影扩散半径,来实现,再设置一个半透明的背景色。

box-shadow: 0 0 0 2000px rgba(0,0,0,.5); 

canvas

先通过 canvas 绘制出全屏的半透明遮罩,然后绘制出高亮部分,通过 globalCompositeOperation 中的 xor选项,将重叠部分变透明。

image-20201104210448475

const canvas = document.getElementById('guide-mask')
const width = window.innerWidth;
const height = window.innerHeight;
canvas.setAttribute("width", width);
canvas.setAttribute("height", height);
const ctx = canvas.getContext("2d");
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, width, height);
ctx.fill();
ctx.fillStyle = 'rgb(255, 255, 255)';
ctx.globalCompositeOperation = "xor";
ctx.fillRect(rect.left - 3, rect.top - 3, rect.width + 3 * 2 + 10 + 5, rect.height + 3 * 2); 

image-20201102235430928

图层拼接z-indexBox-shadowCanvas
兼容性非常好非常好一般一般
难易程度略复杂简单简单略复杂
总评价⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️⭐️

以上所有完整代码仓库: github.com/hua1995116/…

我顺便来介绍一下目前我看到新手引导比较好的几个开源项目。

jquery-pagewalkthrough

优势: 手绘风,适用于特定的网站风格。

缺点: 需要依赖 jQuery。

image-20201103112705958

intro.js

优势: 拥有丰富的蒙层引导示例,可自定义主题

缺点: 个人免费,商业需要付费。

image-20201103112854822

driver.js

优势: MIT 开源,拥有与 intro.js 差不多的功能。

缺点: 示例没有 intro.js 丰富。

image-20201103113117404

至此,本文就到此结束了。

参考

https://zhuanlan.zhihu.com/p/33508501

https://www.zhihu.com/question/20295898

http://www.woshipm.com/ucd/3506054.html

https://juejin.im/post/6844903859786104839

https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API/Tutorial/Compositing

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

查看原文

赞 8 收藏 4 评论 2

蓝色的秋风 赞了文章 · 10月25日

h5微信分享实现(node+express);

前言

(^o^)/~
h5页面在微信中使用微信分享功能,实现个性化分享模版。
从默认样式:
image.png

到自定义样式:
image.png

第一步:注册微信公众平台账号(已有略过此步)

https://mp.weixin.qq.com/cgi-bin/registermidpage?action=index&lang=zh_CN&token=
image.png

个人只能注册订阅号类型,服务号就需要企业认证了。企业权限大一些,能用的接口比较全,分享的话个人的就行。

目前网页端能使用的微信功能(支付除外)都属于公众号的范畴,隶属于微信公众平台,和微信开放平台无关;

注册完登录后可以查看自己的权限,能用那些功能。
image.png

第二步:接入sdk

这一步容易卡关,应为首先需要一台能用的服务器和备案的域名;没办法,微信对接要求很苛刻,本地无法实现对接
https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#1

image.png

安全域名这里要求将某个密钥文件放在服务器根目录。
image.png

下面就要开始写代码了。

第三步:创建服务,放置指定文件(有现成服务的放下文件,这一步跳过)

文件结构如下:
image.png

//server.js
const express = require('express');
const { resolve } = require('path');
const publicPath = resolve('./public');

const server = express();

async function bootstrap(){
    server.use(express.static(publicPath));//托管静态文件
    server.listen(9222,function(){
        console.log('server run at:http://localhost:9222')
    })
}
bootstrap();

启动服务试一下:
image.png

查看文件是否正常查看:
http://localhost:9222/MP_verify_kuKy0AHjAhdrFeBO.txt
image.png
这样就可以放到服务器上去了。
image.png
一般用这两个工具操作服务器就够了。
image.png
将静态资源传上去,node_modules手动安装比较好:
image.png
假定服务器上已经安装好了node,npm,yarn.pm2等工具。
我这里使用yarn安装,用npm也是一样的。

用pm2托管服务:
输入命令pm2 start server.js
image.png
服务启动成功就可以试一下MP_verify_kuKy0AHjAhdrFeBO.txt能否访问。
http://www.liubingyang.top:9222/MP_verify_kuKy0AHjAhdrFeBO.txt
提示:我的是ubuntu系统,默认9222端口不可访问,需打开防火墙限制:

ufw allow xxx//开放接口
ufw status  查看接口放开状态

image.png

image.png
虽然可以访问,但要求是直接访问,不能带端口号,用nginx简单配置下就行:(默认已经安装了nginx)
找到nginx配置文件夹 nginx.conf(每个系统不一致,使用whereis nginx可以粗略查找)
image.png
我的是在 /etc/nginx下,进入nginx.conf文件夹下用vim直接创建编辑一个test.conf文件。
image.png
image.png
只需要如下代码就够了,那两个add_header是用来处理接口跨域请求的,也可以不要。

server {
        listen 80;
        server_name www.liubingyang.top;

        location / {
            add_header 'Access-Control-Allow-Origin' '$http_origin';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            proxy_pass http://127.0.0.1:9222;
        }
}

在线编辑,或者本地编辑完用xftp传上去也可以。
编辑完保存后(编辑输入i命令,编辑完输入ESC->:wq->回车),开启(或重启nginx)

ngixn -s reload

image.png
正常(在linux系统中,没有消息就是最好的消息)
再试试:
http://www.liubingyang.top/MP_verify_kuKy0AHjAhdrFeBO.txt
image.png
文件放置正常;

第三步:页面接入sdk

看文档,页面引入js,并配置初始项。
新建html
image.png
image.png

//inde.html
<!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>
    测试分享
</body>
</html>
<script data-original="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
   wx.config({
        debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
        appId: '', // 必填,公众号的唯一标识
        timestamp: '', // 必填,生成签名的时间戳
        nonceStr: '', // 必填,生成签名的随机串
        signature: '',// 必填,签名
        jsApiList: [] // 必填,需要使用的JS接口列表
    });
</script>

前端做的事情就是把appId,timestamp,nonceStr,signature这4个参数填下就行了,都是必填项。
appId在平台的基本配置中找到(页面拉到最下面);
https://mp.weixin.qq.com/advanced/advanced?action=dev&t=advanced/dev&token=1496518416&lang=zh_CN
image.png

image.png

jsApiList不能为空,就先把分享有关的几个接口先写进去:

jsApiList: ['onMenuShareTimeline','onMenuShareAppMessage','onMenuShareQQ','onMenuShareQZone'] // 必填,需要使用的JS接口列表

timestamp,nonceStr,signature三个参数由服务端对接提供(appId也可以由服务端提供)。
接下来就开始做服务端对接,解决这三个参数的获取。

第四步:服务端获取signature

https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#1

image.png
根据文档的附录(上面这个页面往下翻)描述,可以判断出我们需要的三个参数构成:
timestamp:时间戳(特别提示:就是个随机数字,new Date().getTime()可以获取,但是12位的,这里只要10位,截取一下就行了);
nonceStr:随机码,也是自定义的,这里写死也行。
signature:最重要的签名字段,是由上面两个加上url和jsapi_ticket拼成的字符串,再进行sha1加密得到。
例如:

jsapi_ticket=xx&noncestr=Wm3WZYTPz0wzccnW&timestamp=1414587457&url=http://mp.weixin.qq.com?params=value

加密后会得到一个简短的密文:
image.png

下面就看怎么获取jsapi_ticket:
image.png
分两步,先获取access_token:
https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
image.png
介绍有兴趣慢慢看吧,我只管接口,参数,返回就行了。
grant_type固定值client_credential,secret和appid在一个地方查看。
image.png
新建controller文件夹和config文件夹,新增获取access_token的controller和config文件:

 //config/index.js
 module.exports = {
    grant_type:'client_credential',
    appid:'wx9c48ee416a064a99',//换成自己的,和域名绑定的
    secret:'030fa057e50a2e3eedd42cc2e605edb6',//换成自己的,和域名绑定的

    timestamp:new Date().getTime().toString().slice(0,10),
    nonceStr:'Wm3WZYTPz0wzccnW',

    localPath:'http://www.liubingyang.top',
}

//access_token.js
const { Router } =require("express");
const axios = require('axios');
const config = require('../config');
class Axios {
    async init(){
        const router =  Router();
        router.get('/',this.get);
        return router;
    }
    get = async (req,res)=>{
        let url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.appid}&secret=${config.secret}`;
        const { data } = await axios.get(url);
        res.json(data);
    }
}

module.exports = async function(){
    return await new Axios().init();
}

controller文件入口:

//contriller/index.js
const { Router } = require('express');
const access_tokenController = require('./access_token.js');

module.exports = async function(){
    const router = Router();
    router.use('/access_token', await access_tokenController());
    return router;
}

server.js同样做下修改:

//server.js
const express = require('express');
const { resolve } = require('path');
const bodyParser= require('body-parser');
const server = express();

const publucPath = resolve('./public');

const initControllers = require('./controller');
const bootstrap = async function(){
    server.use(bodyParser.urlencoded({ extended: false }));//处理表单入参
    server.use(bodyParser.json({ extended: false }));//处理json入参
    server.use(express.static(publucPath));
    server.use(await initControllers());
    server.listen(9222,function(){
        console.log('server run at http://localhost:9222');
    })
}
bootstrap();

将修改后的代码放到服务器上:
image.png

安装axios插件,重启pm2:
image.png
在浏览器中试一下接口
http://www.liubingyang.top/access_token
image.png
可以正常返回,下面用access_token换取jsapi_ticket:
image.png
用get方法请求这个接口就可以了。新建jsapi_ticket的controller:
image.png

//jsapi_ticket.js
const { Router } = require('express');
const axios = require('axios');
const urlUtil = require("url");
const querystring = require("querystring");

class Jsapi_ticket {
    async init(){
        const router = Router();
        router.use('/',this.get);
        return router;
    }
    get = async (req,res)=>{
        //获取返回的url对象的query属性值 
        var arg = urlUtil.parse(req.url).query;

        //将arg参数字符串反序列化为一个对象
        var params = querystring.parse(arg);
        const url = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${params.access_token}&type=jsapi`;
        const { data } = await axios.get(url);
        res.json(data);
    }
}

module.exports = async function(){

    return await new Jsapi_ticket().init();
}

controller/index.js别忘注册下接口:
image.png
文件放到服务器上,安装url插件,重启pm2:
image.png
启动成功可以用pm2 logs查看日志,pm2 flush可以清空所有日志。
试一下Jsapi_ticket接口:(access_token有时效性,得用自己刚获取的)
[http://www.liubingyang.top/js...
image.png
这里也正确的拿到了ticket。
接下来开始组装签名signature:
image.png

//signature.js
const { Router } = require('express');
const axios = require('axios'); 
const config = require('../config');
const urlUtil = require("url");
const querystring = require("querystring");

class Signature{
    async init(){
        const router = Router();
        router.get('/',this.get)
        return router;
    }
    get = async (req,res)=>{
        //获取返回的url对象的query属性值 
        var arg = urlUtil.parse(req.url).query;
        console.log('arg:'+arg);
        //将arg参数字符串反序列化为一个对象
        var params = querystring.parse(arg);
        let access_token ='';
        {
            const { data } = await axios.get(`${config.localPath}/access_token`);
            if( data.access_token ){
                access_token = data.access_token;
            }else{
                res.json(data);
                return;
            }
            console.log('access_token:'+access_token);
        }
        {
            const { data } = await axios.get(`${config.localPath}/jsapi_ticket?access_token=${access_token}`);
            let ticket = '';
            if(data.ticket){
                ticket = data.ticket;
            }else{
                res.json(data);
                return
            }
            console.log('ticket:'+ticket);
            console.log('url:'+params.url)
            const signature = `jsapi_ticket=${ticket}&noncestr=${config.nonceStr}&timestamp=${config.timestamp}&url=${params.url}`;
            console.log('signature:'+signature);
            res.json({signature});

        }


    }
}

module.exports = async function(){
    return await new Signature().init();
}

应为是在服务器上调试,多写几个console便于查看问题,
controller/index.js注册接口
image.png

上传文件重启pm2:
image.png
这个时候就开着pm2 logs查看下接口:
http://www.liubingyang.top/signature?url=http://www.liubingyang.top
image.png
image.png
日志也出来了,接口也正常。

最后一步 前端获取参数

最后就是提供前端开接口获前端所有需要的参数:
image.png

//wx_config.js
const { Router } = require('express');
const axios = require('axios');
const sha1 = require('node-sha1');
const config = require('../config');

class Wx_config {
    async init(){
        const router = Router();
        router.use('/',this.post);
        return router;
    }
    post = async (req,res)=>{
        console.log('req.body'+JSON.stringify(req.body))
        console.log('req.body.url:'+req.body.url);
        const { data } = await axios(`${config.localPath}/signature?url=${req.body.url}`);
        let signature = '';

        if(data.signature){
            signature = data.signature;
        }else{
            res.json(data);
            return;
        }
        res.json({
            timestamp : config.timestamp,
            nonceStr : config.nonceStr,
            signature:sha1(signature),
        })
    }
}

module.exports = async function(){
    return await new Wx_config().init();
}

最后更新下controller/index.js
image.png
修改下index.html,这里就不啰嗦了,把需要的都放进去:

//index.html
<!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>
    测试分享
</body>
</html>
<script data-original="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
    const xhr = new XMLHttpRequest();
    function wx_config(){
        return new Promise(res =>{
            xhr.onreadystatechange = function(event){
                if(event.target.status == 200 && event.target.readyState == 4){
                    res(xhr.responseText);
                }
            }
            xhr.open('post', 'http://www.liubingyang.top/wx_config');
            xhr.setRequestHeader("Content-type","application/x-www-form-urlencoded");
            xhr.send(`url=${encodeURIComponent(location.href.split('#')[0])}`);
        })
    }
    let configs = '';
    (async function(){
        configs = await wx_config();
        console.log(configs)
        const { timestamp,nonceStr,signature } = JSON.parse(configs);
        let config = {
            debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
            appId: 'wx9c48ee416a064a99',// 必填,公众号的唯一标识

            timestamp, // 必填,生成签名的时间戳
            nonceStr, // 必填,生成签名的随机串
            signature,// 必填,签名
            jsApiList: ['chooseImage','onMenuShareTimeline','onMenuShareAppMessage','onMenuShareQQ','onMenuShareQZone'] // 必填,需要使用的JS接口列表
        }
        wx.config(config);

        wx.ready(function(){
            let config={ 
                title: '测试用例', // 分享标题
                desc: '你看这个行不行', // 分享描述
                link: location.href, // 分享链接,该链接域名或路径必须与当前页面对应的公众号JS安全域名一致
                type: 'link',//分享类型,music、video或link,不填默认为link,
                imgUrl:'https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1603186815698&di=7ec300630a404299c855c73a99773e17&imgtype=0&data-original=http%3A%2F%2Fcdn.duitang.com%2Fuploads%2Fitem%2F201411%2F04%2F20141104225457_f8mrM.thumb.700_0.jpeg',
                success: function () {
                    alert('分享测试成功')
                }
            }
                wx.onMenuShareTimeline(config)
                wx.onMenuShareAppMessage(config)
                wx.onMenuShareQQ(config)
                wx.onMenuShareQZone(config)
            })
        })()
</script>

文件更新到服务器上,安装node-sha1插件并重启pm2:
image.png
记得先清空日志
查看页面情况
http://www.liubingyang.top/
image.png
开的是debug模式,各个参数都能看到。参数正常,就可以用手机微信测试了:
image.png
表示配置成功,这里最容易出现的错误是invalid signature,签名不正确,解决办法照着这个查就行了
image.png
调用分享给朋友:
image.png
这样就没有问题了。
呈现的结果image.png

总结

在微信的条条框框中探索探索某个功能的实现是比较困难的,尤其是新手,文档像说明书一样,几乎没有逻辑可寻,而且有很多地方更新的太快,而手册又比较老。
比如这两个接口,实际上个人是没有权限的,文档里并没有说明。个人只能用比较老的快废弃的接口
image.png

上面有很多我踩过的坑就不一一说明了,也许你遇不到,也许你遇到的我没遇到过,慢慢探索吧。微信功能实现其实主要由服务端完成,前端做的很少,熟悉对接流程会使前端同学对微信体系有更深的理解,出了问题也能从容的应对。

查看原文

赞 13 收藏 9 评论 0

蓝色的秋风 发布了文章 · 10月10日

一文了解文件上传全过程(1.8w字深度解析,进阶必备)

前言

平常在写业务的时候常常会用的到的是 GET, POST请求去请求接口,GET 相关的接口会比较容易基本不会出错,而对于 POST中常用的 表单提交,JSON提交也比较容易,但是对于文件上传呢?大家可能对这个步骤会比较害怕,因为可能大家对它并不是怎么熟悉,而浏览器Network对它也没有详细的进行记录,因此它成为了我们心中的一根刺,我们老是无法确定,关于文件上传到底是我写的有问题呢?还是后端有问题,当然,我们一般都比较谦虚, 总是会在自己身上找原因,可是往往实事呢?可能就出在后端身上,可能是他接受写的有问题,导致你换了各种请求库去尝试,axiosrequestfetch 等等。那么我们如何避免这种情况呢?我们自身要对这一块够熟悉,才能不以猜的方式去写代码。如果你觉得我以上说的你有同感,那么你阅读完这篇文章你将收获自信,你将不会质疑自己,不会以猜的方式去写代码。

本文比较长可能需要花点时间去看,需要有耐心,我采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下, 先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》文档提出。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。

总结就是原先的规范不满足啦,我要扩充规范了。

文件上传为什么要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?

其实我认为,无论你用什么都可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。

以上为什么文件传输要用multipart/form-data 我还可以举个例子,例如你在中国,你想要去美洲,我们的multipart/form-data相当于是选择飞机,而application/json相当于高铁,但是呢?中国和美洲之间没有高铁啊,你执意要坐高铁去,你可以花昂贵的代价(后端额外解析你的文本)造高铁去美洲,但是你有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?(如果你有钱有时间,抱歉,打扰了,老子给你道歉)

multipart/form-data规范是什么?

摘自 《RFC 1867: Form-based File Upload in HTML》 6.Example

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

可以简单解释一些,首先是请求类型,然后是一个 boundary (分割符),这个东西是干啥的呢?其实看名字就知道,分隔符,当时分割作用,因为可能有多文件多字段,每个字段文件之间,我们无法准确地去判断这个文件哪里到哪里为截止状态。因此需要有分隔符来进行划分。然后再接下来就是声明内容的描述是 form-data 类型,字段名字是啥,如果是文件的话,得知道文件名是啥,还有这个文件的类型是啥,这个也很好理解,我上传一个文件,我总得告诉后端,我传的是个啥,是图片?还是一个txt文本?这些信息肯定得告诉人家,别人才好去进行判断,后面我们也会讲到如果这些没有声明的时候,会发生什么?

好了讲完了这些前置知识,我们接下来要进入我们的主题了。面对File, formData,Blob,Base64,ArrayBuffer,到底怎么做?还有文件上传不仅仅是前端的事。服务端也可以文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer,Stream,Base64....头秃,怎么搞?不急,就是因为上传文件不单单是前端的事,所以我将以下上传文件的一方称为请求端,接受文件一方称为接收方。我会以请求端各种上传方式,接收端是怎么解析我们的文件以及我们最终的杀手锏调试工具-wireshark来进行讲解。以下是讲解的大纲,我们先从浏览器端上传文件,再到服务端上传文件,然后我们再来解析文件是如何被解析的。

file-upload

请求端

浏览端

File

首先我们先写下最简单的一个表单提交方式。

<form action="http://localhost:7787/files" method="POST">
    <input name="file" type="file" id="file">
    <input type="submit" value="提交">
</form> 

我们选择文件后上传,发现后端返回了文件不存在。

image-20200328191433694

不用着急,熟悉的同学可能立马知道是啥原因了。嘘,知道了也听我慢慢叨叨。

我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log 来进行日志追踪。

image-20200328191807526

image-20200328191733536

我们可以发现其实 FormDatafile 字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。

image-20200328192020599

发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded 无法进行文件上传。

我们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
  <input name="file" type="file" id="file">
  <input type="submit" value="提交">
</form> 

image-20200328192539734

发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。

FormData

formData 的方式我随便写了以下几种方式。

<input type="file" id="file">
<button id="submit">上传</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script> submit.onclick = () => {
    const file = document.getElementById('file').files[0];
    var form = new FormData();
    form.append('file', file);
  
    // type 1
    axios.post('http://localhost:7787/files', form).then(res => {
        console.log(res.data);
    })
    // type 2
    fetch('http://localhost:7787/files', {
        method: 'POST',
        body: form
    }).then(res => res.json()).tehn(res => {console.log(res)});
    // type3
    var xhr = new XMLHttpRequest();
    xhr.open('POST', 'http://localhost:7787/files', true);
    xhr.onload = function () {
        console.log(xhr.responseText);
    };
    xhr.send(form);
} </script> 

image-20200328192539734

以上几种方式都是可以的。但是呢,请求库这么多,我随便在 npm 上一搜就有几百个请求相关的库。

image-20200328194431932

因此,掌握请求库的写法并不是我们的目标,目标只有一个还是掌握文件上传的请求头和请求内容。

image-20200328194625420

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

因此如果我们遇到 Blob 方式的文件上方式不用害怕,可以用以下两种方式:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form); 

2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些 https://caniuse.com/#search=File)

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
    
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form) 

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。

虽然它用的比较少,但是他是最贴近文件流的方式了。

在浏览器中,他每个字节以十进制的方式存在。我提前准备了一张图片。

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form) 

这里需要注意的是 new Blob([typedArray.buffer], {type: 'xxx'}),第一个参数是由一个数组包裹。里面是 typedArray 类型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
    byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form); 

关于 base64 的转化和原理可以看这两篇 base64 原理

原来浏览器原生支持JS Base64编码解码

小结

对于浏览器端的文件上传,可以归结出一个套路,所有东西核心思路就是构造出 File 对象。然后观察请求 Content-Type,再看请求体是否有信息缺失。而以上这些二进制数据类型的转化可以看以下表。

transform.77175c26

图片来源 (https://shanyue.tech/post/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5)

服务端

讲完了浏览器端,现在我们来讲服务器端,和浏览器不同的是,服务端上传有两个难点。

1.浏览器没有原生 formData,也不会想浏览器一样帮我们转成二进制形式。

2.服务端没有可视化的 Network 调试器。

Buffer

Request

首先我们通过最简单的示例来进行演示,然后一步一步深入。相信文档可以查看 https://github.com/request/request#multipartform-data-multipart-form-uploads

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: stream,
    }
}, (err, res, body) => {
    console.log(body);
}) 

image-20200328234106276

发现报了一个错误,正像上面所说,浏览器端报错,可以用NetWork。那么服务端怎么办?这个时候我们拿出我们的利器 -- wireshark

我们打开 wireshark (如果没有或者不会的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)

设置配置 tcp.port == 7787,这个是我们后端的端口。

image-20200328234316233

运行上述文件 node request-error.js

image-20200328234543643

我们来找到我们发送的这条http的请求报文。中间那堆乱七八糟的就是我们的文件内容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close

----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream

.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....  pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415-- 

可以看到上述报文。发现我们的内容请求头 Content-Type: application/octet-stream有错误,我们上传的是图片请求头应该是image/png,并且也少了 filename="1.png"

我们来思考一下,我们刚才用的是fs.readFileSync(path.join(__dirname, '../1.png')) 这个函数返回的是 BufferBuffer是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02> 

所以我想到的是,需要指定文件名以及文件格式,幸好 request 也给我们提供了这个选项。

key: {
    value:  fs.createReadStream('/dev/urandom'),
    options: {
      filename: 'topsecret.jpg',
      contentType: 'image/jpeg'
    }
} 

可以指定options,因此正确的代码应该如下(省略不重要的代码)

...
request.post({
    url: 'http://localhost:7787/files',
    formData: {
        file: {
            value: stream,
            options: {
                filename: '1.png'
            }
        },
    }
}); 

我们通过抓包可以进行分析到,文件上传的要点还是规范,大部分的问题,都可以通过规范模板来进行排查,是否构造出了规范的样子。

Form-data

我们再深入一些,来看看 request 的源码, 他是怎么实现Node端的数据传输的。

打开源码我们很容易地就可以找到关于 formData 这块相关的内容 https://github.com/request/request/blob/3.0/request.js#L21

image-20200328235629308

就是利用form-data,我们先来看看 formData 的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
    filename: '1.png',
    contentType: 'image/jpeg',
});
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
    console.log(res.statusCode);
}); 
原生 Node

看完 formData,可能感觉这个封装还是太高层了,于是我打算对照规范手动来构造multipart/form-data请求方式来进行讲解。我们再来回顾一下规范。

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x-- 

我模拟上方,我用原生 Node 写出了一个multipart/form-data 请求的方式。

主要分为4个部分
  • 构造请求header
  • 构造内容header
  • 写入内容
  • 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
    method: 'post',
    host: 'localhost',
    port: '7787',
    path: '/files',
    headers: {
        'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
        'Connection': 'keep-alive'
    }
});
// 写入内容头部
request.write(
    `--${boundaryKey}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
    // 写入尾部
    request.end('rn--' + boundaryKey + '--' + 'rn');
});
request.on('response', function(res) {
    console.log(res.statusCode);
}); 

至此,已经实现服务端上传文件的方式。

Stream、Base64

由于这两块就是和Buffer的转化,比较简单,我就不再重复描述了。可以作为留给大家的作业,感兴趣的可以给我这个示例代码仓库贡献这两个示例。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64'); 
// stream to buffer
function streamToBuffer(stream) {  
  return new Promise((resolve, reject) => {
    const buffers = [];
    stream.on('error', reject);
    stream.on('data', (data) => buffers.push(data))
    stream.on('end', () => resolve(Buffer.concat(buffers))
  });
} 

小结

由于服务端没有像浏览器那样 formData 的原生对象,因此服务端核心思路为构造出文件上传的格式(header,filename等),然后写入 buffer 。然后千万别忘了用 wireshark进行验证。

接收端

这一部分是针对 Node 端进行讲解,对于那些 koa-body 等用惯了的同学,可能一样不太清楚整个过程发生了什么?可能唯一比较清楚的是 ctx.request.files ??? 如果ctx.request.files 不存在,就会懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。

我还是要说到规范...请求端是按照规范来构造请求..那么我们接收端自然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');

app.use(koaBody({ multipart: true })); 

我们来看看最常用的 koa-body,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐(其他源码库一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?

寻求问题的本源,我们当然要打开 koa-body的源码,koa-body 源码很少只有211行,https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 很容易地发现它其实是用了一个叫做formidable的库来解析 files 的。并且把解析好的 files 对象赋值到了 ctx.req.files。(所以说大家不要一味死记 ctx.request.files, 注意查看文档,因为今天用 koa-bodyctx.request.files 明天换个库可能就是 ctx.request.body 了)

因此看完 koa-body我们得出的结论是,koa-body的核心方法是formidable

Formidable

那么让我们继续深入,来看看formidable做了什么,我们首先来看它的目录结构。

.
├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js 

看到这个目录,我们大致可以梳理出这样的关系。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser 

由于源码分析比较枯燥。因此我只摘录比较重要的片段。由于我们是分析文件上传,所以我们只需要关心 multipart_parser 这个文件。

https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72

...
MultipartParser.prototype.write = function(buffer) {
    console.log(buffer);
  var self = this,
      i = 0,
      len = buffer.length,
      prevIndex = this.index,
      index = this.index,
      state = this.state,
... 

我们将它的 buffer 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... > 

我们来看 wireshark 抓到的包

image-20200329144355168

我用红色进行了分割标记,对应的就是formidable所分割的片段 ,所以说这个包主要是将大段的 buffer 进行分割,然后循环处理。

这里我还可以补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位,上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101,右侧是ascii 码用来可视化,但是 assii 分可显和非可显示。有部分是无法可视的。比如你所看到文件中有需要小点,就是不可见字符。

你可以对照,ascii表对照表来看。

我来总结一下formidable对于文件的处理流程。

formible-process

原生 Node

好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
  if (req.url === "/files" && req.method.toLowerCase() === "post") {
    parseFile(req, res)
  }
})
function parseFile(req, res) {
  req.setEncoding("binary");
  let body = "";
  let fileName = "";
  // 边界字符
  let boundary = req.headers['content-type']
    .split('; ')[1]
    .replace("boundary=", "")
  
  req.on("data", function(chunk) {
    body += chunk;
  });
  req.on("end", function() {
    // 按照分解符切分
    const list = body.split(boundary);
    let contentType = '';
    let fileName = '';
    for (let i = 0; i < list.length; i++) {
      if (list[i].includes('Content-Disposition')) {
        const data = list[i].split('rn');
        for (let j = 0; j < data.length; j++) {
          // 从头部拆分出名字和类型
          if (data[j].includes('Content-Disposition')) {
            const info = data[j].split(':')[1].split(';');
            fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
            console.log(fileName);
          }
          if (data[j].includes('Content-Type')) {
            contentType = data[j];
            console.log(data[j].split(':')[1]);
          }
        }
      }
    }
    // 去除前面的请求头
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
     // 去除后面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
      res.end("sucess");
    });
    ;
  })
}

server.listen(7787) 

总结

相信有了以上的介绍,你不再对文件上传有所惧怕, 对文件上传整个过程都会比较清晰了,还不懂。。。。找我。

再次回顾下我们的重点:

请求端出问题,浏览器端打开 network 查看格式是否正确(请求头,请求体), 如果数据不够详细,打开 wireshark,对照我们的规范标准,看下格式(请求头,请求体)。

接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。

其实讲这么多就两个字: 规范,所有的生态都是围绕它而展开的。更多请看我的博客

系列文章

一文带你层层解锁「文件下载」的奥秘_蓝色的秋风 - SegmentFault 思否

⚡️前端多线程大文件下载实践,提速10倍,拿捏百度云盘_蓝色的秋风 - SegmentFault 思否

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

参考

https://juejin.im/post/6844903810079391757

https://my.oschina.net/bing309/blog/3132260

https://segmentfault.com/a/1190000020654277

查看原文

赞 58 收藏 41 评论 0

认证与成就

  • 获得 366 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • webchat

    Websocket project based on vue(基于vue2.0的实时聊天项目)

注册于 2017-11-12
个人主页被 2k 人浏览