Crushdada

Crushdada 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织 Crushdada.gitee.io 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

Crushdada 收藏了文章 · 4月16日

想写好前端,先练好内功

前言

封不平听在耳里,暗叫:“到这地步,我再能隐藏甚么?”仰天一声清啸,斜行而前,长剑横削直击,迅捷无比,未到五六招,剑势中已发出隐隐风声。他出剑越来越快,风声也是渐响,剑锋上所发出的一股劲气渐渐扩展,旁观众人只觉寒气逼人,脸上、手上被疾风刮得隐隐生疼,不由自主的后退,围在相斗两人身周的圈子渐渐扩大,竟有四五丈方圆。泰山派的一个道士在旁说道:“气宗的徒儿剑法高,剑宗的师叔内力强,这到底怎么搞的?华山派的气宗、剑宗,这可不是颠倒来玩了么?

《笑傲江湖》中的“剑宗余孽”封不平本想仗着有嵩山派撑腰,一举夺了华山掌门宝座。可打了半天剑法上占不了便宜,最后只能使出“狂风快剑”,企图以内力取胜。可见,任何高明武功若无内功心法相辅,也是徒劳无功。

说回前端,如今的前端技术栈就如同武侠小说中的江湖一样,各门各派自成一体,可谓“百花齐放”、“百家争鸣”。

这边 React 、Vue 、AngularJS 、JQuery 谁还都谈不上能一统江湖。“武林新贵” Flux 、Redux 、Mobx 们已经忙着争夺谁是数据流框架老大。Native 端 RN 刚偃旗息鼓,Weex 就大有“ I'm the everywhere ”之势。连备受争议的 GraphQL 内部都还有 Apollo、Relay 掐来掐去。

常听到身边的前端工程师抱怨,上周刚发布的 XXX 新版本文档还没看,今天 YYY 公司又发布了新框架,到底先学哪个?其实,无论是哪种框架哪项技术都是解决实际业务需求的手段、方法,和武林中各门各派的武功招式是一样的,各有所长,各有各的独到之处。

我们学习技术,除了了解具体使用方法,还需要掌握技术背后的设计理念和工程思想,这些背后的东西是我们技术选型的依据,是架构设计的基础,是软件系统的灵魂。这就好比是的武功中“内功心法”催动拳脚刀枪,一招一式,虎虎生风,纵有大敌当前,亦是淡然自若。

接下来分别谈一下三种工程思想,分别是:“开闭原则”、“函数式编程”和“消息机制”,这三种工程思想在后端开发中均有广泛的使用,容易被大家忽略的是目前很多前端技术框架也应用了这三种思想,以下结合具体案例分析,希望能够帮助大家加深对技术本身的理解。

开闭原则

说到面向对象设计,大部分人脑海中闪过的恐怕都是“23种设计模式”。设计模式代表的是业务场景中总结出的最佳实现方式,属于实践的范畴,在其之上是更为重要的“SOLID”五大原则:

  • Single Responsibility Principle 单一责任原则
  • The Open Closed Principle 开放封闭原则
  • The Liskov Substitution Principle 里氏替换原则
  • The Dependency Inversion Principle 依赖倒置原则
  • The Interface Segregation Principle 接口分离原则

SOLID 五大原则的出发点也是软件工程的终极目标:“高内聚、低耦合”。在后端开发中运用最多的是“依赖倒置原则”,与其相关的设计模式大约有5-6个。如下图所示:

上图也可以理解为从抽象概念到具体实践的逐步演进。

在前端技术框架中,运用最多的是“开放封闭原则”,我们先来看一下这条原则是怎么定义的:

A software artifact should be open for extension but closed for modification.

翻译过来就是:软件系统应当对扩展开放,对修改封闭(感觉像没说)。这里举一个简单的例子来说明开闭原则,先帮助大家理解概念:

public abstract class Shape
{
    public abstract double Area();    
}

public class Rectangle: Shape
{
    public double Width { get; set;}
    public double Height { get; set;}
    public override double Area()
    {
         return Width*Height'
    }
}

public class Circle: Shape
{
    public double Radius { get; set}
    public override double Area()
    {
        return Radius*Radius*PI;
    }
}

public double Area(Shape [] shapes)
{
    doubel area = 0;
    foreach (var shape in shapes)
    {
        area += shape.Area();
    }
    return area;
}

上例中无论场景如何扩展,Area 函数都无需修改,每个 Shape 类通过继承接口和多态特性,各自实现面积计算。

总结一下开闭原则就是:软件系统的核心逻辑都不应该轻易改变,否则会破坏系统的稳定性和增加测试成本。我们应当建立合适的抽象并统一接口,当业务需要扩展时,我们可以通过增加实体类来完成。

接下来我们看一个“开闭原则”在前端框架中的应用: Ant Design 组件库中的 Form 表单组件。

和其它组件不同,Form 组件并没有具体的形态,它更像是一个容器,提供了接入的标准,并提供了校验、表单提交等功能。绘制表单中的一项如下所示:

<FormItem>
  {getFieldDecorator('userName', {
     rules: [{ required: true, message: 'Please input your username!' }],
     })(<Input prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />}
        placeholder="Username" />
   )}
</FormItem>

Ant Design 组件库中已经提供个几乎所有的常见表单组件,如:Select 、Checkbox 、Radio 、Cascader 等,但在实际业务中,我们还是会需要设计业务相关的表单项,Form 表单通过统一组件接口的方式满足了这个技术需求,具体规约如下:

自定义或第三方的表单控件,也可以与 Form 组件一起使用。只要该组件遵循以下的约定:

  1. 提供受控属性 value 或其它与 valuePropName 的值同名的属性。
  2. 提供 onChange 事件或 trigger 的值同名的事件。
  3. 不能是函数式组件。

具体例子

这正是“开闭原则”的一个典型实践案例,即表单核心逻辑(校验、提交等)保持不变并封装在 Form 组件中,自定义表单项只需要满足上述三条规约,就能平滑接入到 Form 组件中,和 Ant Design 原生组件契合在一起。

Ant Design 中的 Form 组件通过这样一个简洁的设计,完美提供了表单类型页面的统一解决方案。

函数式编程

随着人工智能、区块链、AR、VR、新零售等业务场景的出现,产品界面交互正在变得越来越复杂,这就对现代的前端开发者提出了更高的要求。如何快速、正确、高效地开发出高复杂度页面是目前前端技术最需要解决的问题。

函数式编程(以下简称 FP )凭借其高复用性、易测试性和与之带来的健壮性和简洁开始逐渐占据前端技术圈,我们发现越来越多的前端框架以 FP 为设计核心准则。

我们先简单介绍一下 FP,函数式编程的特征主要包括以下几个方面:

  • 函数为“一等公民”
  • 模块化、组合
  • 引用透明
  • 避免状态改变
  • 避免共享状态

JS 语言中的函数不同于 Java ,C/C++ 等语言, 可以被当做参数和返回值进行传递,因此天生具备“一等公民”特性。“模块化、组合”、“引用透明”、“避免状态改变”、“避免共享状态”这四个特征都需要通过特定代码模式实现。先举两个小例子:

找出字符串中率先出现的四个非数字字符?

非 FP 风格
var words = [], count = 0;
var text = str.split(''); 
for (var i = 0; couont < 4, i < text.length; i++) {   
  if(!text[i].match(/[0-9]/)) {
    words = words.concat(text[i]);
    count++;   
  }
} 
FP 风格
var words = str.split('').filter(function(x){
  return (!x.match(/[1-9]+/))}).slice(0,4);

第二段代码中使用的 js 数组方法 filter 和 slice,去掉了 for 循环,代码更简洁流畅。在写具体业务代码的时候,“模块化、组合”是 FP 最常用的技术,也是最重要的实现功能的手段。

分别实现数组所有元素相加、相乘、相与?

非 FP 风格
function plus(array) {
  var res = array[0];
  for (let i = 1; i < array.length; i++) {
    res += array[i];   
  }
}

function mul(array) {
  var res = array[0];
  for (let i = 1; i < array.length; i++) {
    res *= array[i];
  }
}

function and (array) {
  var res = array[0];
  for (let i = 1; i < array.length; i++) {
    res = res & array[i];
  }
}

plus(array);
mul(array);
and(array);
FP 风格
var ops = { 
  "plus": (x,y)=>x+y,
  "mul" : (x,y)=>x*y,
  "and" : (x,y)=>x&y
}

function operation(op, array) {
  return array.slice(1).reduce(ops[op], array[0]);
} 

operation("plus", array);
operation("mul",  array);
operation("and",  array); 

后一段代码中,使用了 reduce 函数代替了 for 循环,并将数值计算部分作为模块提取出来,当有新的计算类型时,只需要在 ops 对象中定义计算过程。这里就体现了 FP 中“模块化、组合”的特性。在 FP 风格下,我们习惯将复杂逻辑切割成一个个小模块,通过组合这些模块实现新的业务功能,当有新的需求到来时,我们尽可能地复用已有模块达到目标。FP 代码在复用性方面相比 OOD 有明显的优势。

React 中的 FP 思想

React 框架中,当用户操作 UI 或者 API 的返回带来了数据的改变,React 随即进行 virtual dom diff 计算得到 dom 的修改指令,对 dom 元素应用修改指令便得到最新的 html 界面,如下图所示:

不难发现,React 其实是应用数据对UI的一种映射,不同的数据会映射出不同样式的 UI 界面,我们可以得出如下的表达式:

$UI = React(data)$

没错,React 的本质其实是一种函数,并且还是符合 FP 要求的“引用透明”函数。所谓“引用透明”就是指函数的输出仅依赖函数参数,不受任何外部环境影响。这样的函数可测试性强,也非常容易进行组合。

在 React 的体系下,任何组件都可由一个个更小的组件构成,每个组件都只关心自己的输入,他们不断地接受新的数据并输出对应的新的UI界面。React 框架中常用的“高阶组件”可以看作引用透明”函数的组合模式。

在具体业务中我们通常还需要权衡 React 组件的复用性和开发体验,如果组件被拆分的过于细,固然复用性会提升,但文件数量会增加,对应的文档和沟通成本也会增加,这也是 FP 在实践过程中经常遭人诟病的点,即复用性提升后带来的额外开发成本。

消息机制

消息机制是软件工程中一个普遍运用的工程思想。“设计模式”中的观察者模式、Windows 操作系统底层、Spring 框架中的 ApplicationListener 模块、Objective-C 语言中的函数调用、都是通过消息机制驱动的。

使用消息机制最大的好处在于可以做到业务模块间安全解耦,模块间通过发送消息的方式进行协作,我们先举一个后端开发中的例子,下图是一个简单的预定系统的建模图,并没有使用消息机制:

在没有消息机制的情况下,用户模块需要知道订单模块的存在,并向起进行接口调用,同理订单模块需要向支付模块进行接口调用。这种设计下模块间是耦合的。

我们再来看一下使用消息机制的情况:

上图中,无论是客户下订单、支付还是预定都是通过消息的方式传递的,每个模块都是向一个消息处理器起发消息,同时也监听消息处理器发送回来的消息。在这种模式下,模块完全不知道其它模块的存在,彻底做到了解耦。

在前端业务开发中,我们经常也会用到 EventEmitter 库来进行消息传递。比如页面上有两块区域,一块用 React 框架渲染,一块用 D3 渲染的,当两块区域需要数据同步时,就可以使用消息机制进行通讯,保证页面数据整体一致。

如果你的业务中有不同生命周期的组件,建议采用消息机制进行管理,不仅消除了耦合,逻辑关系部分的代码也集中到了一个文件中,内聚性得到了提升

使用消息机制的一个附属产物就是中间件,我们可以为消息定制各种中间件,在中间中完成一些通用逻辑,让业务代码更精炼。

说到前端框架中消息机制的运用,当然首推 Redux 框架,在 Redux 框架中,任何数据交互都需要先转化为一个 action,由 action 去触发 reducer 和相关的 middleware 处理 action,改变数据,最终同步到页面 UI 上,如下图所示:

关于使用 Redux 的种种利弊,在各大社区中都有很深入的讨论,本文不再赘述。

总结

“开闭原则”、“函数式编程”、“消息机制”这个三个软件工程中重要的思想方法好比三套内功口诀,掌握了他们,才能更深刻地理解技术框架本身,发挥出技术框架的最大威力。

写到这里突然又想起《天龙八部》中一段:

乔峰眼见旁人退开,蓦地心念一动,呼的一拳打出,一招“冲阵斩将”,也正是“太祖长拳”中的招数。这一招姿式既潇洒大方已极,劲力更是刚中有柔,柔中有刚,武林高手毕生所盼望达到的拳术完美之境,竟在这一招中表露无遗。

一套平平无奇的“太祖长拳”在乔峰手中尽能有如此气象!

多少年以后,每当人们聊起金庸,聊起那个武侠世界,想必都会津津有味地回味、谈论起聚贤庄中这石破天惊的一拳。

参考

文章可随意转载,但请保留此 原文链接
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj(at)alibaba-inc.com 。
查看原文

Crushdada 赞了文章 · 4月15日

所以你认为你知道 JavaScript 吗?

作者:Chidume Nnamdi
译者:前端小智
来源:smashingmagazine

点赞再看,养成习惯

本文 GitHubhttps://github.com/qq44924588... 上已经收录,更多往期高赞文章的分类,也整理了很多我的文档,和教程资料。欢迎Star和完善,大家面试可以参照考点复习,希望我们一起有点东西。

JavaScript 是一种有趣的语言,我们都喜欢它,因为它的性质。浏览器是JavaScript的主要运行的地方,两者在我们的服务中协同工作。JS有一些概念,人们往往会对它掉以轻心,有时可能会忽略不计。原型、闭包和事件循环等概念仍然是大多数JS开发人员绕道而行的晦涩领域之一。正如我们所知,无知是一件危险的事情,它可能会导致错误。

想阅读更多优质文章请[猛戳GitHub博客][1]\,一年百来篇优质文章等着你!

接下来,来看看几个问题,你也可以试试想想,然后作答。

问题1:浏览器控制台上会打印什么?

var a = 10;
function foo() {
    console.log(a); // ??
    var a = 20;
}
foo();

问题2:如果我们使用 let 或 const 代替 var,输出是否相同

var a = 10;
function foo() {
    console.log(a); // ??
    let a = 20;
}
foo();

问题3:“newArray”中有哪些元素?

var array = [];
for(var i = 0; i <3; i++) {
 array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // ??

问题4:如果我们在浏览器控制台中运行'foo'函数,是否会导致堆栈溢出错误?

function foo() {
  setTimeout(foo, 0); // 是否存在堆栈溢出错误?
};

问题5: 如果在控制台中运行以下函数,页面(选项卡)的 UI 是否仍然响应

function foo() {
  return Promise.resolve().then(foo);
};

问题6: 我们能否以某种方式为下面的语句使用展开运算而不导致类型错误

var obj = { x: 1, y: 2, z: 3 };
[...obj]; // TypeError

问题7:运行以下代码片段时,控制台上会打印什么?

var obj = { a: 1, b: 2 };
Object.setPrototypeOf(obj, {c: 3});
Object.defineProperty(obj, 'd', { value: 4, enumerable: false });

// what properties will be printed when we run the for-in loop?
for(let prop in obj) {
    console.log(prop);
}

问题8:xGetter() 会打印什么值?

var x = 10;
var foo = {
  x: 90,
  getX: function() {
    return this.x;
  }
};
foo.getX(); // prints 90
var xGetter = foo.getX;
xGetter(); // prints ??

答案

现在,让我们从头到尾回答每个问题。我将给您一个简短的解释,同时试图揭开这些行为的神秘面纱,并提供一些参考资料。

问题1: undefined

解析:

使用var关键字声明的变量在JavaScript中会被提升,并在内存中分配值undefined。 但初始化恰发生在你给变量赋值的地方。 另外,var声明的变量是[函数作用域的][2],而letconst是块作用域的。 所以,这就是这个过程的样子:

var a = 10; // 全局使用域
function foo() {
// var a 的声明将被提升到到函数的顶部。
// 比如:var a

console.log(a); // 打印 undefined

// 实际初始化值20只发生在这里
   var a = 20; // local scope
}
    • -

问题 2:ReferenceError:a undefined

解析:

letconst声明可以让变量在其作用域上受限于它所使用的块、语句或表达式。与var不同的是,这些变量没有被提升,并且有一个所谓的暂时死区(TDZ)。试图访问TDZ中的这些变量将引发ReferenceError,因为只有在执行到达声明时才能访问它们。

var a = 10; // 全局使用域
function foo() { // TDZ 开始

// 创建了未初始化的'a'
    console.log(a); // ReferenceError

// TDZ结束,'a'仅在此处初始化,值为20
    let a = 20;
}

下表概述了与JavaScript中使用的不同关键字声明的变量对应的提升行为和使用域:

clipboard.png

    • -

问题 3: [3, 3, 3]

解析:

for循环的头部声明带有var关键字的变量会为该变量创建单个绑定(存储空间)。 阅读更多关于[闭包][3]的信息。 让我们再看一次for循环。

// 误解作用域:认为存在块级作用域
var array = [];
for (var i = 0; i < 3; i++) {
  // 三个箭头函数体中的每个`'i'`都指向相同的绑定,
  // 这就是为什么它们在循环结束时返回相同的值'3'。
  array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [3, 3, 3]

如果使用 let 声明一个具有块级作用域的变量,则为每个循环迭代创建一个新的绑定。

// 使用ES6块级作用域
var array = [];
for (let i = 0; i < 3; i++) {
  // 这一次,每个'i'指的是一个新的的绑定,并保留当前的值。
 // 因此,每个箭头函数返回一个不同的值。
  array.push(() => i);
}
var newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]

解决这个问题的另一种方法是使用[闭包][4]。

let array = [];
for (var i = 0; i < 3; i++) {

  array[i] = (function(x) {
    return function() {
      return x;
    };
  })(i);
}
const newArray = array.map(el => el());
console.log(newArray); // [0, 1, 2]
    • -

问题4 : 不会溢出

解析:

JavaScript并发模型基于“事件循环”。 当我们说“浏览器是 JS 的家”时我真正的意思是浏览器提供运行时环境来执行我们的JS代码。

浏览器的主要组件包括调用堆栈事件循环**,任务队列Web API**。 像setTimeoutsetIntervalPromise这样的全局函数不是JavaScript的一部分,而是 Web API 的一部分。 JavaScript 环境的可视化形式如下所示:

clipboard.png

JS调用栈是后进先出(LIFO)的。引擎每次从堆栈中取出一个函数,然后从上到下依次运行代码。每当它遇到一些异步代码,如setTimeout,它就把它交给Web API(箭头1)。因此,每当事件被触发时,callback 都会被发送到任务队列(箭头2)。

事件循环(Event loop)不断地监视任务队列(Task Queue),并按它们排队的顺序一次处理一个回调。每当调用堆栈(call stack)为空时,Event loop获取回调并将其放入堆栈(stack )(箭头3)中进行处理。请记住,如果调用堆栈不是空的,则事件循环不会将任何回调推入堆栈

现在,有了这些知识,让我们来回答前面提到的问题:

步骤

  1. 调用 foo()会将foo函数放入调用堆栈(call stack)
  2. 在处理内部代码时,JS引擎遇到setTimeout
  3. 然后将foo回调函数传递给WebAPIs(箭头1)并从函数返回,调用堆栈再次为空
  4. 计时器被设置为0,因此foo将被发送到
    任务队列
    (箭头2)。
  5. 由于调用堆栈是空的,事件循环将选择foo回调并将其推入调用堆栈进行处理。
  6. 进程再次重复,堆栈不会溢出。

运行示意图如下所示:

\![图片描述][5]

    • -

问题5 : 不会响应

解析:

大多数时候,开发人员假设在
事件循环图中只有一个任务队列。但事实并非如此,我们可以有多个任务队列。由浏览器选择其中的一个队列并在该队列中处理回调



在底层来看,JavaScript中有宏任务和微任务。setTimeout回调是宏任务,而Promise回调是微任务

主要的区别在于他们的执行方式。宏任务在单个循环周期中一次一个地推入堆栈,但是微任务队列总是在执行后返回到事件循环之前清空。因此,如果你以处理条目的速度向这个队列添加条目,那么你就永远在处理微任务。只有当微任务队列为空时,事件循环才会重新渲染页面、

现在,当你在控制台中运行以下代码段

function foo() {
  return Promise.resolve().then(foo);
};

每次调用'foo'都会继续在微任务队列上添加另一个'foo'回调,因此事件循环无法继续处理其他事件(滚动,单击等),直到该队列完全清空为止。 因此,它会阻止渲染。

    • -

问题6 : 会导致TypeError错误

解析:

[展开语法][6] 和 [for-of][7] 语句遍历iterable对象定义要遍历的数据。ArrayMap 是具有默认迭代行为的内置迭代器。对象不是可迭代的,但是可以通过使用[iterable][8]和[iterator][9]协议使它们可迭代。

Mozilla文档中,如果一个对象实现了@@iterator方法,那么它就是可迭代的,这意味着这个对象(或者它原型链上的一个对象)必须有一个带有@@iterator键的属性,这个键可以通过常量Symbol.iterator获得。

上述语句可能看起来有点冗长,但是下面的示例将更有意义:

var obj = { x: 1, y: 2, z: 3 };
obj[Symbol.iterator] = function() {

  // iterator 是一个具有 next 方法的对象,
  // 它的返回至少有一个对象
  // 两个属性:value&done。

  // 返回一个 iterator 对象
  return {
    next: function() {
      if (this._countDown === 3) {
        const lastValue = this._countDown;
        return { value: this._countDown, done: true };
      }
      this._countDown = this._countDown + 1;
      return { value: this._countDown, done: false };
    },
    _countDown: 0
  };
};
[...obj]; // 打印 [1, 2, 3]

还可以使用 [generator][10] 函数来定制对象的迭代行为:

var obj = {x:1, y:2, z: 3}
obj[Symbol.iterator] = function*() {
  yield 1;
  yield 2;
  yield 3;
}
[...obj]; // 打印 [1, 2, 3]
    • -

问题7 : a, b, c

解析:

for-in循环遍历对象本身的[可枚举属性][11]以及对象从其原型继承的属性。 可枚举属性是可以在for-in循环期间包含和访问的属性。

var obj = { a: 1, b: 2 };
var descriptor = Object.getOwnPropertyDescriptor(obj, "a");
console.log(descriptor.enumerable); // true
console.log(descriptor);
// { value: 1, writable: true, enumerable: true, configurable: true }

现在你已经掌握了这些知识,应该很容易理解为什么我们的代码要打印这些特定的属性

var obj = { a: 1, b: 2 }; //a,b 都是 enumerables 属性

// 将{c:3}设置为'obj'的原型,并且我们知道
// for-in 循环也迭代 obj 继承的属性
// 从它的原型,'c'也可以被访问。
Object.setPrototypeOf(obj, { c: 3 });

// 我们在'obj'中定义了另外一个属性'd',但是 
// 将'enumerable'设置为false。 这意味着'd'将被忽略。
Object.defineProperty(obj, "d", { value: 4, enumerable: false });

for (let prop in obj) {
  console.log(prop);
}
// 打印
// a
// b
// c
    • -

\#\#\#\# 问题8 : 10

解析:

在全局范围内初始化x时,它成为window对象的属性(不是严格的模式)。看看下面的代码:

var x = 10; // global scope
var foo = {
  x: 90,
  getX: function() {
    return this.x;
  }
};
foo.getX(); // prints 90
let xGetter = foo.getX;
xGetter(); // prints 10

咱们可以断言:

window.x === 10; // true

this 始终指向调用方法的对象。因此,在foo.getx()的例子中,它指向foo对象,返回90的值。而在xGetter()的情况下,this指向 window对象, 返回 window 中的x的值,即10

要获取 foo.x的值,可以通过使用Function.prototype.bindthis的值绑定到foo对象来创建新函数。

let getFooX = foo.getX.bind(foo);
getFooX(); // 90

就这样! 如果你的所有答案都正确,那么干漂亮。 咱们都是通过犯错来学习的。 这一切都是为了了解背后的“原因”。

    • -

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

原文:https://dev.to/aman_singh/so-...

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

[1]: https://www.fundebug.com/?utm\_source=xiaozhi

查看原文

赞 6 收藏 5 评论 0

Crushdada 赞了文章 · 4月15日

你应该经常使用的7种Vue.js模式

说实话,阅读文档并不是我们大多数人喜欢的事情,但当使用像Vue这样不断发展的现代前端框架时,很多东西会随着每一个新版本的发布而改变,你可能会错过一些后来推出的新的闪亮功能。让我们看一下那些有趣但不那么流行的功能,请记住,所有这些都是Vue文档的一部分。

1.处理加载状态

在大型应用程序中,我们可能需要将应用程序划分为更小的块,只有在需要时才从服务器加载组件。为了使这一点更容易,Vue允许你将你的组件定义为一个工厂函数,它异步解析你的组件定义。Vue只有在需要渲染组件时才会触发工厂函数,并将缓存结果,以便将来重新渲染。2.3版本的新功能是,异步组件工厂也可以返回一个如下格式的对象。

const AsyncComponent = () => ({
  // 要加载的组件(应为Promise)
  component: import('./MyComponent.vue'),
  // 异步组件正在加载时要使用的组件
  loading: LoadingComponent,
  // 加载失败时使用的组件
  error: ErrorComponent,
  // 显示加载组件之前的延迟。默认值:200ms。
  delay: 200,
  // 如果提供并超过了超时,则会显示error组件。默认值:无穷。
  timeout: 3000
})

通过这种方法,你有额外的加载和错误状态、组件获取的延迟和超时等选项。

2.廉价的“v-once”静态组件

在Vue中渲染纯HTML元素的速度非常快,但有时你可能有一个包含大量静态内容的组件。在这种情况下,你可以通过在根元素中添加 v-once 指令来确保它只被评估一次,然后进行缓存,就像这样。

Vue.component('terms-of-service', {
  template: `
    <div v-once>
      <h1>Terms of Service</h1>
      ... a lot of static content ...
    </div>
  `
})

3.递归组件

组件可以在自己的模板中递归调用自己,但是,它们只能通过 name 选项来调用。

如果你不小心,递归组件也可能导致无限循环:

name: 'stack-overflow',
template: '<div><stack-overflow></stack-overflow></div>'

像上面这样的组件会导致“超过最大堆栈大小”的错误,所以要确保递归调用是有条件的即(使用 v-if 最终将为 false

4.内联模板

当特殊属性 inline-template 存在于一个子组件上时,该组件将使用它的内部内容作为它的模板,而不是将其视为分布式内容,这允许更灵活的模板编写。

<my-component inline-template>
  <div>
    <p>These are compiled as the component's own template.</p>
    <p>Not parent's transclusion content.</p>
  </div>
</my-component>

5.动态指令参数

指令参数可以是动态的。例如,在 v-mydirective:[argument]=“value" 中, argument 可以根据组件实例中的数据属性更新!这使得我们的自定义指令可以灵活地在整个应用程序中使用。

这是一条指令,其中可以根据组件实例更新动态参数:

<div id="dynamicexample">
  <h3>Scroll down inside this section ↓</h3>
  <p v-pin:[direction]="200">I am pinned onto the page at 200px to the left.</p>
</div>
Vue.directive('pin', {
  bind: function (el, binding, vnode) {
    el.style.position = 'fixed'
    var s = (binding.arg == 'left' ? 'left' : 'top')
    el.style[s] = binding.value + 'px'
  }
})

new Vue({
  el: '#dynamicexample',
  data: function () {
    return {
      direction: 'left'
    }
  }
})

6.事件和键修饰符

对于 .passive.capture.once 事件修饰符,Vue提供了可与 on 一起使用的前缀:

例如:

on: {
  '!click': this.doThisInCapturingMode,
  '~keyup': this.doThisOnce,
  '~!mouseover': this.doThisOnceInCapturingMode
}

对于所有其他的事件和键修饰符,不需要专有的前缀,因为你可以在处理程序中使用事件方法。

7.依赖注入(Provide/Inject)

有几种方法可以让两个组件在 Vue 中进行通信,它们各有优缺点。在2.2版本中引入的一种新方法是使用Provide/Inject的依赖注入。

这对选项一起使用,允许一个祖先组件作为其所有子孙的依赖注入器,无论组件层次结构有多深,只要它们在同一个父链上。如果你熟悉React,这与React的上下文功(context)能非常相似。

// parent component providing 'foo'
var Provider = {
  provide: {
    foo: 'bar'
  },
  // ...
}

// child component injecting 'foo'
var Child = {
  inject: ['foo'],
  created () {
    console.log(this.foo) // => "bar"
  }
  // ...
}
查看原文

赞 12 收藏 9 评论 0

Crushdada 赞了文章 · 4月8日

给顶级开源项目 Spring Boot 贡献代码是一种什么样的体验?

先点赞再看,养成好习惯

背景

Spring Boot的默认日志框架一直是 Logback,支持的很好。而且针对Logback,Spring Boot还提供了一个扩展功能 - <springProfile>,这个标签可以在Logback的XML配置文件中使用,用于配合Spring的profile来区分环境,非常方便。

比如你可以像下面这样,只配置一个logback-spring.xml配置文件,然后用<springProfile>来区分环境,开发环境只输出到控制台,而其他环境输出到文件

<Root level="INFO">
  <!-- 开发环境使用Console Appender,生产环境使用File Appender -->
  <springProfile name="dev">
    <AppenderRef ref="Console"/>
  </springProfile>
  <SpringProfile name="!dev">
    <AppenderRef ref="File"/>
  </SpringProfile>
</Root>

这样做的好处是,我只需要一个logback.xml配置文件,就可以解决多环境的问题,而不是每个环境一个logback-spring.xml,实在太香了(这个Profile 的语法还可以有一些更灵活的语法(详细参考Spring Boot的官方文档))

但是有时候为了性能或其他原因,我们会选择log4j2作为Spring Boot的日志框架。Spirng Boot当然也是支持log4j2的。

切换到 log4j2 虽然很简单,但是Spring Boot并没有对 log4j2进行扩展!log4j2的xml配置方式,并不支持<SpringProfile>标签,不能愉快的配置多环境!搜索了一下,StackOverflow上也有人有相同的困惑,而且这个功能目前并没有任何人提供

于是,我萌生了一个大胆的想法 :自己开发一个Spring Boot - Log4j2 XML的扩展,让 log4j2 的XML也支持<SpringProfile>标签,然后贡献给Spring Boot,万一被采纳了岂不妙哉。

而且这可不是改个注释,改个标点符号,改个变量名之类的PR;这可是一个新 feature,一旦被采纳,Spring Boot的文档上就会有我的一份力了!
image.png

功能开发

说干就干,先分析Log4j2 XML解析的源码,看看好不好下手

Log4j2 XML解析源码分析

经过一阵分析,找到了 Log4j2 的 XML 文件解析代码在 org.apache.logging.log4j.core.config.xml.XmlConfiguration,仔细阅读+DEBUG这个类之后,发现这个XML解析类各种解析方法不是static就是private,设计之初就没有考虑过提供扩展,定制标签的功能。比如这个递归解析标签的方法,直接就是private的:

private void constructHierarchy(final Node node, final Element element) {
        processAttributes(node, element);
        final StringBuilder buffer = new StringBuilder();
        final NodeList list = element.getChildNodes();
        final List<Node> children = node.getChildren();
        for (int i = 0; i < list.getLength(); i++) {
            final org.w3c.dom.Node w3cNode = list.item(i);
            if (w3cNode instanceof Element) {
                final Element child = (Element) w3cNode;
                final String name = getType(child);
                final PluginType<?> type = pluginManager.getPluginType(name);
                final Node childNode = new Node(node, name, type);
                constructHierarchy(childNode, child);
                if (type == null) {
                    final String value = childNode.getValue();
                    if (!childNode.hasChildren() && value != null) {
                        node.getAttributes().put(name, value);
                    } else {
                        status.add(new Status(name, element, ErrorType.CLASS_NOT_FOUND));
                    }
                } else {
                    children.add(childNode);
                }
            } else if (w3cNode instanceof Text) {
                final Text data = (Text) w3cNode;
                buffer.append(data.getData());
            }
        }

        final String text = buffer.toString().trim();
        if (text.length() > 0 || (!node.hasChildren() && !node.isRoot())) {
            node.setValue(text);
        }
    }

连解析后的数据,也是private

private Element rootElement;

想通过继承的方式,只重写部分方法来实现根本不可能,除非重写整个类才能扩展自定义的标签……

风险 & 兼容性的思考

这下就尴尬了,重写整个类虽然也可以,但兼容性就得不到保证了。因为一旦Log4j2 的 XML配置有更新,我这套扩展就废了,不管是大更新还是小更新,但凡是这个类有变动我这个扩展就得跟着重写,实在不稳妥。

但我在查看了XmlConfiguration这个类的提交历史后发现,它最近一次更新的时间在2019年6月
image.png

而整个Log4j2框架 ,在2019年6月到2021年3月之间,发布了9次Release版本
image.png

整个项目更新了两年,快十个版本中,XmlConfiguration 只更新过一次,说明更新频率很低。而且对比变更记录发现,这个类近几次的更新内容也很少。

这么一想,我就算重写XmlConfiguration又怎么样,这么低的更新频率,这么少的更新内容,重写的风险也很低啊。而且我也不是全部重写,只是拷贝原有的代码,加上一点自定义标签的支持而已,改动量并不大。就算需要跟着Log4j2 更新的话,对比一下代码,重新调整一遍也不是难事。

就这样我说服了自己,开始拉代码……

fork/clone 代码,本地环境搭建

spring-boot 仓库地址:https://github.com/spring-projects/spring-boot

  1. Fork一份 Spring Boot的代码
  2. clone 这个fork的仓库
  3. 基于master,新建一个log4j2_enhancement分支用于开发

这里也可以直接通过IDEA clone,不过前提是你有个“可靠又稳定”的网络

由于Spring/Spring Boot已经将构建工具从Maven迁移到了Gradle,所以IDEA版本最好不要太老,太老的版本可能对Gradle支持的不够好。

如果你的网络足够“可靠和稳定”,那么只需要在IDEA中打开Spring Boot的源码,就可以自定构建好开发环境,直接运行测试了。否则可能会遇到Gradle和相关包下载失败,Maven仓库包下载失败等各种问题……

Spring Boot对Logback的支持扩展

既然Spring Boot对Logback(XML)进行了增强,那么先来看看它是怎么增强的,待会我支持Log4j2的话能省很多事。

经过一阵分析,找到了这个Logback的扩展点:

class SpringBootJoranConfigurator extends JoranConfigurator {

    private LoggingInitializationContext initializationContext;

    SpringBootJoranConfigurator(LoggingInitializationContext initializationContext) {
        this.initializationContext = initializationContext;
    }

    @Override
    public void addInstanceRules(RuleStore rs) {
        super.addInstanceRules(rs);
        Environment environment = this.initializationContext.getEnvironment();
        rs.addRule(new ElementSelector("configuration/springProperty"), new SpringPropertyAction(environment));
        rs.addRule(new ElementSelector("*/springProfile"), new SpringProfileAction(environment));
        rs.addRule(new ElementSelector("*/springProfile/*"), new NOPAction());
    }
}

就……这么简单?顺着这个类又分析了一遍JoranConfigurator和相关的类之后,发现这都是Logback的功劳。

Logback文档中提到,这个Joran 实际上是一个通用的配置系统,可以独立于日志系统使用。但我搜索了一下,除了Logback的文档以外,并没有找到这个Joran的出处在哪。

不过这并不重要,我就把他当做一个通用的配置解析器,被logback引用了而已。

这个解析器比较灵活,可以自定义标签/标签解析的行为,只需要重写addInstanceRules这个方法,添加自定义的标签名和行为类即可:

@Override
public void addInstanceRules(RuleStore rs) {
    super.addInstanceRules(rs);
    Environment environment = this.initializationContext.getEnvironment();
    rs.addRule(new ElementSelector("configuration/springProperty"), new SpringPropertyAction(environment));
    //就是这么简单……
    rs.addRule(new ElementSelector("*/springProfile"), new SpringProfileAction(environment));
    rs.addRule(new ElementSelector("*/springProfile/*"), new NOPAction());
}

然后在SpringProfileAction中,通过Spring的Environment对象,拿到当前激活的Profiles进行匹配就能搞定

如法炮制,添加Log4j2 的自定义扩展

虽然Log4j2的XML解析并不能像Logback那样灵活,直接插入扩展。但是基于我前面的风险&兼容性分析,重写XmlConfiguration也是可以实现自定义标签解析的:

先创建一个SpringBootXmlConfiguration

这个类的代码,是完全复制了org.apache.logging.log4j.core.config.xml.XmlConfiguration,然后增加俩Environment相关的参数:

private final LoggingInitializationContext initializationContext;

private final Environment environment;

接着在构造函数中增加initializationContext并注入:

public SpringBootXmlConfiguration(final LoggingInitializationContext initializationContext,
            final LoggerContext loggerContext, final ConfigurationSource configSource) {
        super(loggerContext, configSource);
        this.initializationContext = initializationContext;
        this.environment = initializationContext.getEnvironment();
        ...
}

最后只需要调整上面提到的递归解析方法,增加SpringProfile标签的支持即可:

private void constructHierarchy(final Node node, final Element element, boolean profileNode) {
    //SpringProfile节点不需要处理属性
    if (!profileNode) {
        processAttributes(node, element);
    }
    final StringBuilder buffer = new StringBuilder();
    final NodeList list = element.getChildNodes();
    final List<Node> children = node.getChildren();
    for (int i = 0; i < list.getLength(); i++) {
        final org.w3c.dom.Node w3cNode = list.item(i);
        if (w3cNode instanceof Element) {
            final Element child = (Element) w3cNode;

            final String name = getType(child);
            //如果是<SpringProfile>标签,就跳过plugin的查找和解析
            // Enhance log4j2.xml configuration
            if (SPRING_PROFILE_TAG_NAME.equalsIgnoreCase(name)) {
                //如果定义的Profile匹配当前激活的Profiles,就递归解析子节点,否则就跳过当前节点(和子节点)
                if (acceptsProfiles(child.getAttribute("name"))) {
                    constructHierarchy(node, child, true);
                }
                // Break <SpringProfile> node
                continue;
            }
            //查找节点对应插件,解析节点,添加到node,构建rootElement树
            //......
    }
}
//判断profile是否符合规则,从Spring Boot - Logback里复制的……
private boolean acceptsProfiles(String profile) {
    if (this.environment == null) {
        return false;
    }
    String[] profileNames = StringUtils.trimArrayElements(StringUtils.commaDelimitedListToStringArray(profile));
    if (profileNames.length == 0) {
        return false;
    }
    return this.environment.acceptsProfiles(Profiles.of(profileNames));
}

在配置SpringBootXmlConfiguration的入口

好了,大功告成,就这么简单,这么点代码就完成了Log4j2 XML的增强。现在只需要在装配Log4j2的时候,将默认的XmlConfiguration换成我的SpringBootXmlConfiguration即可:

//org.springframework.boot.logging.log4j2.Log4J2LoggingSystem
......
LoggerContext ctx = getLoggerContext();
URL url = ResourceUtils.getURL(location);
ConfigurationSource source = getConfigurationSource(url);
Configuration configuration;
if (url.toString().endsWith("xml") && initializationContext != null) {
    //XML文件并且initializationContext不为空时,就使用增强的SpringBootXmlConfiguration进行解析
    configuration = new SpringBootXmlConfiguration(initializationContext, ctx, source);
}
else {
    configuration = ConfigurationFactory.getInstance().getConfiguration(ctx, source);
}
......

准备单元测试

功能已经完成了,现在要准备单元测试。这里还是可以参考Logback 相关的单元测试类,直接拷贝过来,修改成Log4j2的版本。

Spring Boot目前的版本使用的是Junit5,现在新建一个SpringBootXmlConfigurationTests类,然后模仿Logback的单元测试类写一堆测试方法和测试配置文件:

<!--profile-expression.xml-->
<springProfile name="production | test">
  <logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>

<!--production-file.xml-->
<springProfile name="production">
  <logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>

<!--multi-profile-names.xml-->
<springProfile name="production, test">
  <logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
</springProfile>

<!--nested.xml-->
<springProfile name="outer">
  <springProfile name="inner">
    <logger name="org.springframework.boot.logging.log4j2" level="TRACE" />
  </springProfile>
</springProfile>

...
void profileActive();
void multipleNamesFirstProfileActive();
void multipleNamesSecondProfileActive();
void profileNotActive();
void profileExpressionMatchFirst();
void profileExpressionMatchSecond();
void profileExpressionNoMatch();
void profileNestedActiveActive();
void profileNestedActiveNotActive();
......

折腾了一会,终于把单元测试编写完成,并全部测试通过。接下来可以准备提PR了

提交PR

首先,在fork后的项目中,进行Pull request
image.png

然后,选择要pr的分支,创建pr即可
image.png
然后需要详细填写你这个PR的描述
image.png

我详细的描述了我提交的功能,以及我上面分析的兼容性和风险问题:

Enhance the configuration of log4j2 (xml), support Profile-specific Configuration (<SpringProfile>), consistent with logback extension.
Spring Boot currently only enhances the Logback (XML) configuration to support the tag. This feature is very useful, but is not supported by Log4j2.
I copied the code in Log4j2 XML to parse the XML configuration and created a new SpringBootXmlConfiguration to support the tag, which is as simple and easy to use as Logback Extension.
Compatibility issues with rewriting the Log4j2 parsing code:

  1. I just copied the XmlConfiguration code directly from Log4j2, adding very little code and making no big changes like formatting. If there is an update to Log4j2, it is easy to rewrite the parsing class and update it accordingly.
  2. The XmlConfiguration class in Log4j2 was last updated in June 2019, with no updates between [2.12.0,2.14.1] and the default dependent version of Log4j2 in Springboot (master) is 2.14.1

To sum up, there is no risk in this kind of enhancement

被冷漠无情的CI检查卡住

在提交PR后,我以为事情到这里就告一段落了……

结果Spring Boot的Github Action有一个CI检查,漫长的等待之后,告诉我构建失败……
image.png

这里details可以进入详情查看具体构建日志
image.png

checkFormat/checkStyle 失败……

卧草大意了,忘了有checkStyle了,这种开源项目对代码风格要求一定很严格,我的代码是从Log4j2拷过来的,两个项目代码风格标准肯定不一样!

调整代码风格

我又回过头去翻Spring Boot的贡献指南,发现他们提到了一个spring-javaformat插件,用于检查/格式化代码,Eclipse/Idea插件都有,还有gradle/maven插件。

我天真的以为,这个IDEA插件可以很方便的把我的代码格式化成Spring 的规范,装上之后,Reformat Code发现并没有什么卵用,仍然过不了checkstyle………有知道怎么用的同学,可以在评论区分享下

然后我就开始在本地执行它的checkstyle task,不断的调整代码风格……

这个checkstyle/checkformat的执行,是通过Gradle执行的,所以也可以在IDEA 的Gradle面板上执行:
image.png

Spring Boot的代码风格非常严谨,比如注释必须加句号啊,文件尾部必须空行结尾啊,导包顺序要求啊,每行代码长度要求啊等等等等……非常多

在执行checkstyle/checkformat插件后,插件会提示你哪个文件,哪一行有什么问题,跟着修改就行

经过我一个多小时的调整,终于通过了代码检查……眼镜都花了

再次提交代码

代码风格/格式调整完成后,我又一次的提交了代码,还是原来的分支。这里提交的话,那个PR里的CI检查会自动触发。

大概过了二十多分钟,终于构建完成,并且通过
image.png

来自官方人员的回复

过了三四天,我收到了官方人员的回复,随之而来的是我提交的PR被关闭了……
image.png

官方的回复态度还是很友好的,大概意思是,无论我提交的代码稳定性如何,但这种暴力重写的方式还是不太好,他们希望由Log4j2来提供一个扩展,然后Spring Boot通过扩展来实现对Log4j2的增强。

并且附上了一个issue,主题就是Spring Boot 对Log4j2支持的问题,并且追加了我这次的PR:
https://github.com/spring-projects/spring-boot/issues/22149

image.png

总结

虽然Spring Boot没有接受我贡献的代码,但并不是因为我的代码写的屎 😂,而是这种方式侵入性太强,有风险,并不够友好,通过扩展的方式去实现会更好。

这也体现了程序的扩展性是多么重要,在设计程序或者框架的时候,一定要多考虑扩展性,遵循开闭原则。

这次拒绝了我的贡献也不要紧,至少Spring Boot官方了解到有这个需求,并且有现成的实现代码,日后有机会的话,我还是会继续贡献其他的代码。

附录

这次提交的代码,和相关的PR地址都在这了,有兴趣的同学可以参考一下。

原创不易,未经授权禁止转载。如果我的文章对您有帮助,请点赞/收藏/关注鼓励支持一下吧❤❤❤❤❤❤
查看原文

赞 16 收藏 3 评论 3

Crushdada 赞了文章 · 4月3日

前端性能优化 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 是发出页面请求到接收到应答数据第一个字节的时间。
  • 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 好。
  • 缺点:配置麻烦,增加了服务器的计算压力。

下面我用 Vue SSR 做示例,简单的描述一下 SSR 过程。

客户端渲染过程

  1. 访问客户端渲染的网站。
  2. 服务器返回一个包含了引入资源语句和 <div id="app"></div> 的 HTML 文件。
  3. 客户端通过 HTTP 向服务器请求资源,当必要的资源都加载完毕后,执行 new Vue() 开始实例化并渲染页面。

服务端渲染过程

  1. 访问服务端渲染的网站。
  2. 服务器会查看当前路由组件需要哪些资源文件,然后将这些文件的内容填充到 HTML 文件。如果有 ajax 请求,就会执行它进行数据预取并填充到 HTML 文件里,最后返回这个 HTML 页面。
  3. 当客户端接收到这个 HTML 页面时,可以马上就开始渲染页面。与此同时,页面也会加载资源,当必要的资源都加载完毕后,开始执行 new Vue() 开始实例化并接管页面。

从上述两个过程中可以看出,区别就在于第二步。客户端渲染的网站会直接返回 HTML 文件,而服务端渲染的网站则会渲染完页面再返回这个 HTML 文件。

这样做的好处是什么?是更快的内容到达时间 (time-to-content)

假设你的网站需要加载完 abcd 四个文件才能渲染完毕。并且每个文件大小为 1 M。

这样一算:客户端渲染的网站需要加载 4 个文件和 HTML 文件才能完成首页渲染,总计大小为 4M(忽略 HTML 文件大小)。而服务端渲染的网站只需要加载一个渲染完毕的 HTML 文件就能完成首页渲染,总计大小为已经渲染完毕的 HTML 文件(这种文件不会太大,一般为几百K,我的个人博客网站(SSR)加载的 HTML 文件为 400K)。这就是服务端渲染更快的原因

参考资料:

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 条建议尽情地去优化你的网站,加油!

参考资料:

其他参考资料

更多文章,欢迎关注

查看原文

赞 254 收藏 175 评论 10

Crushdada 赞了回答 · 3月30日

解决JavaScript return函数中的this指向问题

obj.fn()()obj.fn()是不一样的,obj.fn()可以理解为obj调用它的fn函数,此时fn的调用者是obj,所以fn里的this指向obj,那么obj.fn()()呢,这是obj.fn()先返回一个函数,然后这个函数再调用,此时这个函数相当于是无主的,里面的this在非严格模式下指向window,严格模式下是undefined

关注 2 回答 2

Crushdada 提出了问题 · 3月30日

解决JavaScript return函数中的this指向问题

题目描述

以下代码:
var name = "林二二";

var obj = {
  name: "林一一",
  fn: function () {
    var name = "小三";
    return function () {
      return this.name;
    };
  },
};
console.log(obj.fn()()); // 林二二

为什么输出是全局的name?

自己的思路

上面代码之所以输出全局下的name,是因为this指向了window
但是为什么它会指向window?
this不是应该指向调用它的调用者吗?
原因是因为它是匿名函数还是和‘return’的机制有关?

关注 2 回答 2

Crushdada 关注了专栏 · 3月28日

前端面试每日3+1

前端面试每日 3+1,以面试题来驱动学习,提倡每日学习与思考,每天进步一点!每天早上5点纯手工发布面试题(死磕自己,愉悦大家)

关注 237

Crushdada 赞了文章 · 3月28日

前端面试每日 3+1 —— 第712天

今天的知识点 (2021.03.28) —— 第712天 (我也要出题)

《论语》,曾子曰:“吾日三省吾身”(我每天多次反省自己)。
前端面试每日3+1题,以面试题来驱动学习,每天进步一点!
让努力成为一种习惯,让奋斗成为一种享受!
相信 坚持 的力量!!!

欢迎在 Issues 和朋友们一同讨论学习!

项目地址:前端面试每日3+1
【推荐】欢迎跟 jsliang 一起折腾前端,系统整理前端知识,目前正在折腾 LeetCode,打算打通算法与数据结构的任督二脉。GitHub 地址

微信公众号

欢迎大家前来讨论,如果觉得对你的学习有一定的帮助,欢迎点个Star, 同时欢迎微信扫码关注 前端剑解 公众号,并加入 “前端学习每日3+1” 微信群相互交流(点击公众号的菜单:交流)。


  • 学习不打烊,充电加油只为遇到更好的自己,365天无节假日,每天早上5点纯手工发布面试题(死磕自己,愉悦大家)。
  • 希望大家在这浮夸的前端圈里,保持冷静,坚持每天花20分钟来学习与思考。
  • 在这千变万化,类库层出不穷的前端,建议大家不要等到找工作时,才狂刷题,提倡每日学习!(不忘初心,html、css、javascript才是基石!)
  • 欢迎大家到Issues交流,鼓励PR,感谢Star,大家有啥好的建议可以加我微信一起交流讨论!
希望大家每日去学习与思考,这才达到来这里的目的!!!(不要为了谁而来,要为自己而来!)

交流讨论

欢迎大家前来讨论,如果觉得对你的学习有一定的帮助,欢迎点个[Star]

查看原文

赞 2 收藏 1 评论 0

Crushdada 赞了文章 · 3月26日

javascript作用域,作用域链,[[scope]]属性

对于Javascript程序员来说,闭包总会让你觉得既熟悉又陌生,然而它对于开发人员来说却非常重要,javascript里的许多设计模式中都用到了闭包,此处以函数作用域为例。

//示例代码
    var a=1;
    function foo(){
        var b=2;
        console.log(a);
        function bar(){
            var c=123;
            console.log(b);
        }
        bar();
    }
    foo();

任何函数定义的时候,都会创建一个[[scope]]属性,这个对象对应的是一个对象的列表,列表中的对象仅能javascript内部访问,没法通过语法访问,用代码可以表示为:
1.函数定义时
在全局环境下定义了一个foo函数,此时foo函数的[[scope]]属性中只包含一个全局对象GO(global object)


    //伪代码
    //js代码默认进入全局执行环境,所以foo在初始时就被定义
    foo.[[scope]]={
        GO:{
        this:window,
        window:{...},
        document:{...},
        a:undefined   //此处是预编译,所以a并没有赋值
        ....
        }
    }
   //当进入foo执行环境时,bar函数才被定义
   bar.[[scope]]={
        AO(foo):{
            this:window,
            arguments:[],
            b:undefined
        },
        GO:{
            this:window,
            window:{...},
            document:{...},
            a:1
        }
    }

2.函数被调用时
执行环境
在函数执行时,会创建一个叫做执行环境/执行上下文(execution context)的内部对象
它定义了一个函数执行时的环境
函数每次执行时的执行环境独一无二
多次调用函数就多次创建执行环境
并且函数执行完毕后,执行环境就会被销毁
执行环境有自己的作用域链,用于解析标识符

所以当foo函数被调用的时候,会创建foo执行环境,每个执行环境对应一个变量对象。首先会创一个它自己的活动对象【Activation Object】(这个对象中包含了this、参数(arguments)、局部变量(包括命名的参数)的定义,当然全局对象是没有arguments的)和一个变量对象的作用域链[[scope chain]],然后,把这个执行环境的[[scope]]按顺序复制到[[scope chain]]里,最后把这个活动对象推入到[[scope chain]]的顶部。这样[[scope chain]]就是一个有序的栈,这样保了对执行环境有权访问的所有变量和对象的有序访问。

    //foo函数被调用时
    foo.EC={         //foo的执行环境
      AO:{           //foo的活动对象
        this:window,
        arguments:[],
        b:undefined
        },
      [[scope chain]]:{
            AO:AO,   //推入作用域链顶部的活动对象
            GO:{...} //通过复制foo.[[scope]]得到的全局对象
        }
      ...
    }
    //函数的作用域链
    foo.EC.[[scope chain]]={
        AO:{
            this:window,
            arguments:[],
            b:undefined
        },
        GO:{
            this:window,
            window:{...},
            document:{...},
            a:1
        }
    }
   //当bar函数被调用时
    bar.EC={
        AO:{
            this:window,
            arguments:[],
            c:undefined
        },
        [[scope chain]]:{
            AO:AO     //推入作用域链顶部的活动对象
            AO:{...}  //foo活动对象
            GO:{...}  //全局活动对象
        }
    }
    

3.函数代码执行阶段
var b=2 实际上就是对作用域链AO对象中的b进行赋值,当执行console.log(a)时候,遇到标识符a,就会根据标识符的名称在执行环境(Execution Context)的作用域链中进行搜索。从作用域链的第一个对象(该函数的Activation Object对象)开始,如果没有找到,就搜索作用域链中的下一个对象,如此往复,直到找到了标识符的定义。如果在搜索完作用域中的最后一个对象,也就是全局对象(Global Object)以后也没有找到,则会抛出一个错误,提示undefined。

正式由于作用域链的这种关系,我们就不难理解,为什么this和arguments不能通过作用域链向上搜索,因为对this和arguments的搜索在当前执行函数的活动对象就停止了。

以上是个人对于js作用域的理解, 如有错误欢迎讨论,本文未涉及with等改变作用域的行为
参考文章
http://www.cnblogs.com/pigtai...
http://blog.csdn.net/liujie19...
http://www.cnblogs.com/vadar/...
http://blog.csdn.net/q1056843...

查看原文

赞 3 收藏 4 评论 0

Crushdada 回答了问题 · 3月26日

解决JavaScript let 块级作用域对作用域链的影响?

  • 每次执行到循环体中的setTimeout方法,该方法都会将调用的回调函数放入“任务队列中”,等待主线程(执行栈中)的事件全部执行完毕后,执行队列头的事件。
  • 第一次循环后,任务队列中只有一个被setTimeout方法放入的回调函数,其作用域链中记录的是i的初始值0
  • 由于let 声明的变量只存在于块级作用域内,因此每一次循环体执行完毕销毁该变量,然后在for循环出的新块let声明一个新的变量j,按for循环原本既定的顺序为其赋值,然后执行循环体
  • 因此第二次循环时,任务队列中的回调函数的作用域链中,记录的是新创建的,重新被赋值为1的变量i
  • 正是由于块级作用域相互独立互不影响,才不会覆盖j的值,就此,我有点理解为什么let能防止数据污染了(还有es6规定let不能重复声明这一点)

关注 5 回答 7

Crushdada 赞了回答 · 3月26日

解决JavaScript let 块级作用域对作用域链的影响?

var声明的当然也被记录了,但是var由于是函数级作用域,在for循环里并不会重复产生新变量,而是原来的变量++,最后变成了3

关注 5 回答 7

Crushdada 赞了回答 · 3月26日

解决JavaScript let 块级作用域对作用域链的影响?

你对 let 的理解没有错,造成 var 的诡异行为的原因是早期 JS 没有块级作用域,那时候所用的关键字 var 声明的变量是在就近的函数作用域里。
所以

;(function(){
    'use strict';
    // var 在这里有效
    if (true) {
        var someUniqueVariation = 123;
    }
    console.log('someUniqueVariation:', someUniqueVariation);
})();

可以执行;

;(function(){
    'use strict';
    // let 在这里无效
    if (true) {
        // let 只在这里有效
        let someUniqueVariation = 123;
    }
    console.log('someUniqueVariation:', someUniqueVariation);
})();

会报错。

关注 5 回答 7

Crushdada 提出了问题 · 3月25日

解决JavaScript let 块级作用域对作用域链的影响?

题目描述:

for (var i = 0; i < 3; i++) {  
      setTimeout(function () {
        console.log(i);  //依次输出3,3,3
      }, 1000);
    }

for (let j = 0; j < 3; j++) {
  setTimeout(function () {
    console.log(j);  //依次输出0,1,2
  }, 1000);

为什么用let会输出0、1、2?

  • 经百度以及个人思考,
  • 得出的结果是
  • 函数在声明时会同时保存当时的作用域链,就是说当时的活动变量
  • 在setTimeout中使用变量时会沿着作用域链去寻找该变量,
    对于用上面let 声明的循环来说,由于块级作用域,每次循环相当于--

    {
      let j = 0;
      function () {
        console.log(j); 
      }
    }
       //第二次循环
    {
      let j = 1;
      function () {
        console.log(j); 
      }
    }
       //第三次循环
    {
      let j = 2;
      function () {
        console.log(j); 
      }

    这些块级作用域相互独立,互不影响
    综上,用let,输出的才是0、1、2
    问题

对于所给两段for循环过程中,JavaScript围绕let变量、块级作用域、作用域链这几个关键词到底做了什么?

关注 5 回答 7

Crushdada 回答了问题 · 3月23日

fatal: cannot read property code of undefined

建议直接找到本地博客存储博文的地方,手动添加md文件即可,百度都有,懒的百度可以参考我的博客
crushdada.gitee.io
找到my_blog分类

关注 5 回答 4

Crushdada 赞了文章 · 3月23日

浅谈Vue.use

先举个?

我们先来看一个简单的事例
首先我使用官方脚手架新建一个项目vue init webpack vue-demo
然后我创建两个文件index.js plugins.js.
我将这两个文件放置在src/classes/vue-use目录下

接下来对这两个文件进行编写

// 文件:  src/classes/vue-use/plugins.js

const Plugin1 = {
    install(a, b, c) {
        console.log('Plugin1 第一个参数:', a);
        console.log('Plugin1 第二个参数:', b);
        console.log('Plugin1 第三个参数:', c);
    },
};

function Plugin2(a, b, c) {
    console.log('Plugin2 第一个参数:', a);
    console.log('Plugin2 第二个参数:', b);
    console.log('Plugin2 第三个参数:', c);
}

export { Plugin1, Plugin2 };
// 文件: src/classes/vue-use/index.js

import Vue from 'vue';

import { Plugin1, Plugin2 } from './plugins';

Vue.use(Plugin1, '参数1', '参数2');
Vue.use(Plugin2, '参数A', '参数B');

然后我们在入口文件main.js引用这段代码

// 文件: src/main.js

import Vue from 'vue';

import '@/classes/vue-use';
import App from './App';
import router from './router';

Vue.config.productionTip = false;

/* eslint-disable no-new */
new Vue({
    el: '#app',
    router,
    render: h => h(App),
});

此时我们执行npm run dev打开8080端口开启开发调试工具可以看到控制台输出以下信息
clipboard.png...]

从中可以发现我们在plugin1中的install方法编写的三个console都打印出来,第一个打印出来的是Vue对象,第二个跟第三个是我们传入的两个参数。
plugin2没有install方法,它本身就是一个方法,也能打印三个参数,第一个是Vue对象,第二个跟第三个也是我们传入的两个参数。

那么现在我们是不是大概对Vue.use有一个模糊的猜想~

分析源码

好我们还是不要猜想,直接上源码

// Vue源码文件路径:src/core/global-api/use.js

import { toArray } from '../util/index'

export function initUse (Vue: GlobalAPI) {
  Vue.use = function (plugin: Function | Object) {
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }

    // additional parameters
    const args = toArray(arguments, 1)
    args.unshift(this)
    if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args)
    } else if (typeof plugin === 'function') {
      plugin.apply(null, args)
    }
    installedPlugins.push(plugin)
    return this
  }
}

从源码中我们可以发现vue首先判断这个插件是否被注册过,不允许重复注册。
并且接收的plugin参数的限制是Function | Object两种类型。
对于这两种类型有不同的处理。
首先将我们传入的参数整理成数组 => const args = toArray(arguments, 1)
(toArray源码)

// Vue源码文件路径:src/core/shared/util.js

export function toArray (list: any, start?: number): Array<any> {
  start = start || 0
  let i = list.length - start
  const ret: Array<any> = new Array(i)
  while (i--) {
    ret[i] = list[i + start]
  }
  return ret
}

再将Vue对象添加到这个数组的起始位置args.unshift(this),这里的this 指向Vue对象
如果我们传入的plugin(Vue.use的第一个参数)的install是一个方法。也就是说如果我们传入一个对象,对象中包含install方法,那么我们就调用这个plugininstall方法并将整理好的数组当成参数传入install方法中。 => plugin.install.apply(plugin, args)
如果我们传入的plugin就是一个函数,那么我们就直接调用这个函数并将整理好的数组当成参数传入。 => plugin.apply(null, args)
之后给这个插件添加至已经添加过的插件数组中,标示已经注册过 => installedPlugins.push(plugin)
最后返回Vue对象。

小结

通过以上分析我们可以知道,在我们以后编写插件的时候可以有两种方式。
一种是将这个插件的逻辑封装成一个对象最后将最后在install编写业务代码暴露给Vue对象。这样做的好处是可以添加任意参数在这个对象上方便将install函数封装得更加精简,可拓展性也比较高。
还有一种则是将所有逻辑都编写成一个函数暴露给Vue。
其实两种方法原理都一样,无非第二种就是将这个插件直接当成install函数来处理。
个人觉得第一种方式比较合理。
举个?

export const Plugin = {
    install(Vue) {
        Vue.component...
        Vue.mixins...
        Vue...
        // 我们也可以在install里面执行其他函数,Vue会将this指向我们的插件
        console.log(this)  // {install: ...,utils: ...}
        this.utils(Vue)    // 执行utils函数
        console.log(this.COUNT) // 0
    },
    utils(Vue) {
        Vue...
        console.log(Vue)  // Vue
    },
    COUNT: 0    
}
// 我们可以在这个对象上添加参数,最终Vue只会执行install方法,而其他方法可以作为封装install方法的辅助函数

const test = 'test'
export function Plugin2(Vue) {
    Vue...
    console.log(test)  // 'test'
    // 注意如果插件编写成函数形式,那么Vue只会把this指向null,并不会指向这个函数
    console.log(this)  // null
}
// 这种方式我们只能在一个函数中编写插件逻辑,可封装性就不是那么强了

小弟不才,对vue源码的理解暂且到这。欢迎大佬们多指教~

查看原文

赞 120 收藏 91 评论 18

Crushdada 回答了问题 · 3月22日

封装过的axios挂载在vue原型链上,就无法在created里调用吗?

第一个问题:
Vue在调用created时已经创建完data、methods等。
由于题主没有贴代码,也没有报错,
一个原因是由于--
在使用axios的时候里面的this是undefined,而不是vue
因此需要让this指向外层的实例对象
方法:

  • 在axios外用变量var this=this
  • then的时候使用箭头函数

第二个问题:
你问的是DOM吧?
computed的初始化是在beforeCreatedcreated之间完成的
紧跟着data、property、methods、watch初始化配置完成之后执行,

image.png

因此无法获取DOM

关注 3 回答 3

Crushdada 赞了回答 · 3月22日

解决将一个矩形用另一个矩形裁剪,问如何将剩余部分用一条直线分为两个面积相等的部分

这个题好像是见过啊。网上肯定有。
我猜一个答案,单独矩形,穿过中心点就是平分咯。那么两个矩形,就是同时传过两个矩形的中心点咯


查了一下,好像就是我这个思路

image.png

image.png

关注 2 回答 1

Crushdada 提出了问题 · 3月20日

解决将一个矩形用另一个矩形裁剪,问如何将剩余部分用一条直线分为两个面积相等的部分

题目描述:
将一个矩形用另一个矩形裁剪,问如何将剩余部分用一条直线分为两个面积相等的部分

题目来源:
前端智力题

个人思考:

  • 百度无果。
  • 题目没有规定两个矩形大小,也没有说明裁剪的位置,在条件不定的情况下,个人感觉无法用数学计算的方法求解。

期待一个较符合逻辑的回答

关注 2 回答 1

Crushdada 提出了问题 · 3月20日

解决将一个矩形用另一个矩形裁剪,问如何将剩余部分用一条直线分为两个面积相等的部分

题目描述:
将一个矩形用另一个矩形裁剪,问如何将剩余部分用一条直线分为两个面积相等的部分

题目来源:
前端智力题

个人思考:

  • 百度无果。
  • 题目没有规定两个矩形大小,也没有说明裁剪的位置,在条件不定的情况下,个人感觉无法用数学计算的方法求解。

期待一个较符合逻辑的回答

关注 2 回答 1