蒋鹏飞

蒋鹏飞 查看完整档案

成都编辑四川大学  |  计算机科学与技术 编辑  |  填写所在公司/组织 github.com/dennis-jiang/Front-End-Knowledges 编辑
编辑

前端工程师,底层技术人。
思否2020年度“Top Writer”!
掘金“优秀作者”!
开源中国2020年度“优秀源创作者”!
分享各种大前端进阶知识!
关注公众号【进击的大前端】第一时间获取高质量原创。
更多文章和示例源码请看:https://github.com/dennis-jia...

个人动态

蒋鹏飞 发布了文章 · 4月1日

webpack核心模块tapable源码解析

上一篇文章我写了tapable的基本用法,我们知道他是一个增强版版的发布订阅模式,本文想来学习下他的源码。tapable的源码我读了一下,发现他的抽象程度比较高,直接扎进去反而会让人云里雾里的,所以本文会从最简单的SyncHook发布订阅模式入手,再一步一步抽象,慢慢变成他源码的样子。

本文可运行示例代码已经上传GitHub,大家拿下来一边玩一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code

SyncHook的基本实现

上一篇文章已经讲过SyncHook的用法了,我这里就不再展开了,他使用的例子就是这样子:

const { SyncHook } = require("tapable");

// 实例化一个加速的hook
const accelerate = new SyncHook(["newSpeed"]);

// 注册第一个回调,加速时记录下当前速度
accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

// 再注册一个回调,用来检测是否超速
accelerate.tap("OverspeedPlugin", (newSpeed) => {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin", "您已超速!!");
  }
});

// 触发一下加速事件,看看效果吧
accelerate.call(500);

其实这种用法就是一个最基本的发布订阅模式,我之前讲发布订阅模式的文章讲过,我们可以仿照那个很快实现一个SyncHook

class SyncHook {
    constructor(args = []) {
        this._args = args;       // 接收的参数存下来
        this.taps = [];          // 一个存回调的数组
    }

    // tap实例方法用来注册回调
    tap(name, fn) {
        // 逻辑很简单,直接保存下传入的回调参数就行
        this.taps.push(fn);
    }

    // call实例方法用来触发事件,执行所有回调
    call(...args) {
        // 逻辑也很简单,将注册的回调一个一个拿出来执行就行
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i];
            fn(...args);
        }
    }
}

这段代码非常简单,是一个最基础的发布订阅模式,使用方法跟上面是一样的,将SyncHooktapable导出改为使用我们自己的:

// const { SyncHook } = require("tapable");
const { SyncHook } = require("./SyncHook");

运行效果是一样的:

image-20210323153234354

注意: 我们构造函数里面传入的args并没有用上,tapable主要是用它来动态生成call的函数体的,在后面讲代码工厂的时候会看到。

SyncBailHook的基本实现

再来一个SyncBailHook的基本实现吧,SyncBailHook的作用是当前一个回调返回不为undefined的值的时候,阻止后面的回调执行。基本使用是这样的:

const { SyncBailHook } = require("tapable");    // 使用的是SyncBailHook

const accelerate = new SyncBailHook(["newSpeed"]);

accelerate.tap("LoggerPlugin", (newSpeed) =>
  console.log("LoggerPlugin", `加速到${newSpeed}`)
);

// 再注册一个回调,用来检测是否超速
// 如果超速就返回一个错误
accelerate.tap("OverspeedPlugin", (newSpeed) => {
  if (newSpeed > 120) {
    console.log("OverspeedPlugin", "您已超速!!");

    return new Error('您已超速!!');
  }
});

// 由于上一个回调返回了一个不为undefined的值
// 这个回调不会再运行了
accelerate.tap("DamagePlugin", (newSpeed) => {
  if (newSpeed > 300) {
    console.log("DamagePlugin", "速度实在太快,车子快散架了。。。");
  }
});

accelerate.call(500);

他的实现跟上面的SyncHook也非常像,只是call在执行的时候不一样而已,SyncBailHook需要检测每个回调的返回值,如果不为undefined就终止执行后面的回调,所以代码实现如下:

class SyncBailHook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];          
    }

    tap(name, fn) {
        this.taps.push(fn);
    }

    // 其他代码跟SyncHook是一样的,就是call的实现不一样
    // 需要检测每个返回值,如果不为undefined就终止执行
    call(...args) {
        const tapsLength = this.taps.length;
        for(let i = 0; i < tapsLength; i++) {
            const fn = this.taps[i];
            const res = fn(...args);

            if( res !== undefined) return res;
        }
    }
}

然后改下SyncBailHook从我们自己的引入就行:

// const { SyncBailHook } = require("tapable"); 
const { SyncBailHook } = require("./SyncBailHook"); 

运行效果是一样的:

image-20210323155857678

抽象重复代码

现在我们只实现了SyncHookSyncBailHook两个Hook而已,上一篇讲用法的文章里面总共有9个Hook,如果每个Hook都像前面这样实现也是可以的。但是我们再仔细看下SyncHookSyncBailHook两个类的代码,发现他们除了call的实现不一样,其他代码一模一样,所以作为一个有追求的工程师,我们可以把这部分重复的代码提出来作为一个基类:Hook类。

Hook类需要包含一些公共的代码,call这种不一样的部分由各个子类自己实现。所以Hook类就长这样:

const CALL_DELEGATE = function(...args) {
    this.call = this._createCall();
    return this.call(...args);
};

// Hook是SyncHook和SyncBailHook的基类
// 大体结构是一样的,不一样的地方是call
// 不同子类的call是不一样的
// tapable的Hook基类提供了一个抽象接口compile来动态生成call函数
class Hook {
    constructor(args = []) {
        this._args = args;       
        this.taps = [];          

        // 基类的call初始化为CALL_DELEGATE
        // 为什么这里需要这样一个代理,而不是直接this.call = _createCall()
        // 等我们后面子类实现了再一起讲
        this.call = CALL_DELEGATE;
    }

    // 一个抽象接口compile
    // 由子类实现,基类compile不能直接调用
    compile(options) {
      throw new Error("Abstract: should be overridden");
    }

    tap(name, fn) {
        this.taps.push(fn);
    }

    // _createCall调用子类实现的compile来生成call方法
    _createCall() {
      return this.compile({
        taps: this.taps,
        args: this._args,
      });
    }
}

官方对应的源码看这里:https://github.com/webpack/tapable/blob/master/lib/Hook.js

子类SyncHook实现

现在有了Hook基类,我们的SyncHook就需要继承这个基类重写,tapable在这里继承的时候并没有使用class extends,而是手动继承的:

const Hook = require('./Hook');

function SyncHook(args = []) {
    // 先手动继承Hook
      const hook = new Hook(args);
    hook.constructor = SyncHook;

    // 然后实现自己的compile函数
    // compile的作用应该是创建一个call函数并返回
        hook.compile = function(options) {
        // 这里call函数的实现跟前面实现是一样的
        const { taps } = options;
        const call = function(...args) {
            const tapsLength = taps.length;
            for(let i = 0; i < tapsLength; i++) {
                const fn = this.taps[i];
                fn(...args);
            }
        }

        return call;
    };
    
    return hook;
}

SyncHook.prototype = null;

注意:我们在基类Hook构造函数中初始化this.callCALL_DELEGATE这个函数,这是有原因的,最主要的原因是确保this的正确指向。思考一下假如我们不用CALL_DELEGATE,而是直接this.call = this._createCall()会发生什么?我们来分析下这个执行流程:

  1. 用户使用时,肯定是使用new SyncHook(),这时候会执行const hook = new Hook(args);
  2. new Hook(args)会去执行Hook的构造函数,也就是会运行this.call = this._createCall()
  3. 这时候的this指向的是基类Hook的实例,this._createCall()会调用基类的this.compile()
  4. 由于基类的complie函数是一个抽象接口,直接调用会报错Abstract: should be overridden

那我们采用this.call = CALL_DELEGATE是怎么解决这个问题的呢

  1. 采用this.call = CALL_DELEGATE后,基类Hook上的call就只是被赋值为一个代理函数而已,这个函数不会立马调用。
  2. 用户使用时,同样是new SyncHook(),里面会执行Hook的构造函数
  3. Hook构造函数会给this.call赋值为CALL_DELEGATE,但是不会立即执行。
  4. new SyncHook()继续执行,新建的实例上的方法hook.complie被覆写为正确方法。
  5. 当用户调用hook.call的时候才会真正执行this._createCall(),这里面会去调用this.complie()
  6. 这时候调用的complie已经是被正确覆写过的了,所以得到正确的结果。

子类SyncBailHook的实现

子类SyncBailHook的实现跟上面SyncHook的也是非常像,只是hook.compile实现不一样而已:

const Hook = require('./Hook');

function SyncBailHook(args = []) {
    // 基本结构跟SyncHook都是一样的
      const hook = new Hook(args);
    hook.constructor = SyncBailHook;

    
    // 只是compile的实现是Bail版的
        hook.compile = function(options) {
        const { taps } = options;
        const call = function(...args) {
            const tapsLength = taps.length;
            for(let i = 0; i < tapsLength; i++) {
                const fn = this.taps[i];
                const res = fn(...args);

                if( res !== undefined) break;
            }
        }

        return call;
    };
    
    return hook;
}

SyncBailHook.prototype = null;

抽象代码工厂

上面我们通过对SyncHookSyncBailHook的抽象提炼出了一个基类Hook,减少了重复代码。基于这种结构子类需要实现的就是complie方法,但是如果我们将SyncHookSyncBailHookcomplie方法拿出来对比下:

SyncHook:

hook.compile = function(options) {
  const { taps } = options;
  const call = function(...args) {
    const tapsLength = taps.length;
    for(let i = 0; i < tapsLength; i++) {
      const fn = this.taps[i];
      fn(...args);
    }
  }

  return call;
};

SyncBailHook

hook.compile = function(options) {
  const { taps } = options;
  const call = function(...args) {
    const tapsLength = taps.length;
    for(let i = 0; i < tapsLength; i++) {
      const fn = this.taps[i];
      const res = fn(...args);

      if( res !== undefined) return res;
    }
  }

  return call;
};

我们发现这两个complie也非常像,有大量重复代码,所以tapable为了解决这些重复代码,又进行了一次抽象,也就是代码工厂HookCodeFactoryHookCodeFactory的作用就是用来生成complie返回的call函数体,而HookCodeFactory在实现时也采用了Hook类似的思路,也是先实现了一个基类HookCodeFactory,然后不同的Hook再继承这个类来实现自己的代码工厂,比如SyncHookCodeFactory

创建函数的方法

在继续深入代码工厂前,我们先来回顾下JS里面创建函数的方法。一般我们会有这几种方法:

  1. 函数申明

    function add(a, b) {
      return a + b;
    }
  2. 函数表达式

    const add = function(a, b) {
      return a + b;
    }

但是除了这两种方法外,还有种不常用的方法:使用Function构造函数。比如上面这个函数使用构造函数创建就是这样的:

const add = new Function('a', 'b', 'return a + b;');

上面的调用形式里,最后一个参数是函数的函数体,前面的参数都是函数的形参,最终生成的函数跟用函数表达式的效果是一样的,可以这样调用:

add(1, 2);    // 结果是3

注意:上面的ab形参放在一起用逗号隔开也是可以的:

const add = new Function('a, b', 'return a + b;');    // 这样跟上面的效果是一样的

当然函数并不是一定要有参数,没有参数的函数也可以这样创建:

const sayHi = new Function('alert("Hello")');

sayHi(); // Hello

这样创建函数和前面的函数申明和函数表达式有什么区别呢?使用Function构造函数来创建函数最大的一个特征就是,函数体是一个字符串,也就是说我们可以动态生成这个字符串,从而动态生成函数体。因为SyncHookSyncBailHookcall函数很像,我们可以像拼一个字符串那样拼出他们的函数体,为了更简单的拼凑,tapable最终生成的call函数里面并没有循环,而是在拼函数体的时候就将循环展开了,比如SyncHook拼出来的call函数的函数体就是这样的:

"use strict";
var _x = this._x;
var _fn0 = _x[0];
_fn0(newSpeed);
var _fn1 = _x[1];
_fn1(newSpeed);

上面代码的_x其实就是保存回调的数组taps,这里重命名为_x,我想是为了节省代码大小吧。这段代码可以看到,_x,也就是taps里面的内容已经被展开了,是一个一个取出来执行的。

SyncBailHook最终生成的call函数体是这样的:

"use strict";
var _x = this._x;
var _fn0 = _x[0];
var _result0 = _fn0(newSpeed);
if (_result0 !== undefined) {
    return _result0;
    ;
} else {
    var _fn1 = _x[1];
    var _result1 = _fn1(newSpeed);
    if (_result1 !== undefined) {
        return _result1;
        ;
    } else {
    }
}

这段生成的代码主体逻辑其实跟SyncHook是一样的,都是将_x展开执行了,他们的区别是SyncBailHook会对每次执行的结果进行检测,如果结果不是undefined就直接return了,后面的回调函数就没有机会执行了。

创建代码工厂基类

基于这个目的,我们的代码工厂基类应该可以生成最基本的call函数体。我们来写个最基本的HookCodeFactory吧,目前他只能生成SyncHookcall函数体:

class HookCodeFactory {
    constructor() {
        // 构造函数定义两个变量
        this.options = undefined;
        this._args = undefined;
    }

    // init函数初始化变量
    init(options) {
        this.options = options;
        this._args = options.args.slice();
    }

    // deinit重置变量
    deinit() {
        this.options = undefined;
        this._args = undefined;
    }

    // args用来将传入的数组args转换为New Function接收的逗号分隔的形式
    // ['arg1', 'args'] --->  'arg1, arg2'
    args() {
        return this._args.join(", ");
    }

    // setup其实就是给生成代码的_x赋值
    setup(instance, options) {
        instance._x = options.taps.map(t => t);
    }

    // create创建最终的call函数
    create(options) {
        this.init(options);
        let fn;

        // 直接将taps展开为平铺的函数调用
        const { taps } = options;
        let code = '';
        for (let i = 0; i < taps.length; i++) {
            code += `
                var _fn${i} = _x[${i}];
                _fn${i}(${this.args()});
            `
        }

        // 将展开的循环和头部连接起来
        const allCodes = `
            "use strict";
            var _x = this._x;
        ` + code;

        // 用传进来的参数和生成的函数体创建一个函数出来
        fn = new Function(this.args(), allCodes);

        this.deinit();  // 重置变量

        return fn;    // 返回生成的函数
    }
}

上面代码最核心的其实就是create函数,这个函数会动态创建一个call函数并返回,所以SyncHook可以直接使用这个factory创建代码了:

// SyncHook.js

const Hook = require('./Hook');
const HookCodeFactory = require("./HookCodeFactory");

const factory = new HookCodeFactory();

// COMPILE函数会去调用factory来生成call函数
const COMPILE = function(options) {
    factory.setup(this, options);
    return factory.create(options);
};

function SyncHook(args = []) {
        const hook = new Hook(args);
    hook.constructor = SyncHook;

    // 使用HookCodeFactory来创建最终的call函数
    hook.compile = COMPILE;

    return hook;
}

SyncHook.prototype = null;

让代码工厂支持SyncBailHook

现在我们的HookCodeFactory只能生成最简单的SyncHook代码,我们需要对他进行一些改进,让他能够也生成SyncBailHookcall函数体。你可以拉回前面再仔细观察下这两个最终生成代码的区别:

  1. SyncBailHook需要对每次执行的result进行处理,如果不为undefined就返回
  2. SyncBailHook生成的代码其实是if...else嵌套的,我们生成的时候可以考虑使用一个递归函数

为了让SyncHookSyncBailHook的子类代码工厂能够传入差异化的result处理,我们先将HookCodeFactory基类的create拆成两部分,将代码拼装的逻辑单独拆成一个函数:

class HookCodeFactory {
    // ...
      // 省略其他一样的代码
      // ...

    // create创建最终的call函数
    create(options) {
        this.init(options);
        let fn;

        // 拼装代码头部
        const header = `
            "use strict";
            var _x = this._x;
        `;

        // 用传进来的参数和函数体创建一个函数出来
        fn = new Function(this.args(),
            header +
            this.content());         // 注意这里的content函数并没有在基类HookCodeFactory实现,而是子类实现的

        this.deinit();

        return fn;
    }

    // 拼装函数体
      // callTapsSeries也没在基类调用,而是子类调用的
    callTapsSeries() {
        const { taps } = this.options;
        let code = '';
        for (let i = 0; i < taps.length; i++) {
            code += `
                var _fn${i} = _x[${i}];
                _fn${i}(${this.args()});
            `
        }

        return code;
    }
}

上面代码里面要特别注意create函数里面生成函数体的时候调用的是this.content,但是this.content并没与在基类实现,这要求子类在使用HookCodeFactory的时候都需要继承他并实现自己的content函数,所以这里的content函数也是一个抽象接口。那SyncHook的代码就应该改成这样:

// SyncHook.js

// ... 省略其他一样的代码 ...

// SyncHookCodeFactory继承HookCodeFactory并实现content函数
class SyncHookCodeFactory extends HookCodeFactory {
    content() {
        return this.callTapsSeries();    // 这里的callTapsSeries是基类的
    }
}

// 使用SyncHookCodeFactory来创建factory
const factory = new SyncHookCodeFactory();

const COMPILE = function (options) {
    factory.setup(this, options);
    return factory.create(options);
};

注意这里:子类实现的content其实又调用了基类的callTapsSeries来生成最终的函数体。所以这里这几个函数的调用关系其实是这样的:

image-20210401111739814

那这样设计的目的是什么呢为了让子类content能够传递参数给基类callTapsSeries,从而生成不一样的函数体。我们马上就能在SyncBailHook的代码工厂上看到了。

为了能够生成SyncBailHook的函数体,我们需要让callTapsSeries支持一个onResult参数,就是这样:

class HookCodeFactory {
    // ... 省略其他相同的代码 ...

    // 拼装函数体,需要支持options.onResult参数
    callTapsSeries(options) {
        const { taps } = this.options;
        let code = '';
        let i = 0;

        const onResult = options && options.onResult;
        
        // 写一个next函数来开启有onResult回调的函数体生成
        // next和onResult相互递归调用来生成最终的函数体
        const next = () => {
            if(i >= taps.length) return '';

            const result = `_result${i}`;
            const code = `
                var _fn${i} = _x[${i}];
                var ${result} = _fn${i}(${this.args()});
                ${onResult(i++, result, next)}
            `;

            return code;
        }

        // 支持onResult参数
        if(onResult) {
            code = next();
        } else {
              // 没有onResult参数的时候,即SyncHook跟之前保持一样
            for(; i< taps.length; i++) {
                code += `
                    var _fn${i} = _x[${i}];
                    _fn${i}(${this.args()});
                `
            }
        }

        return code;
    }
}

然后我们的SyncBailHook的代码工厂在继承工厂基类的时候需要传一个onResult参数,就是这样:

const Hook = require('./Hook');
const HookCodeFactory = require("./HookCodeFactory");

// SyncBailHookCodeFactory继承HookCodeFactory并实现content函数
// content里面传入定制的onResult函数,onResult回去调用next递归生成嵌套的if...else...
class SyncBailHookCodeFactory extends HookCodeFactory {
    content() {
        return this.callTapsSeries({
            onResult: (i, result, next) =>
                `if(${result} !== undefined) {\nreturn ${result};\n} else {\n${next()}}\n`,
        });
    }
}

// 使用SyncHookCodeFactory来创建factory
const factory = new SyncBailHookCodeFactory();

const COMPILE = function (options) {
    factory.setup(this, options);
    return factory.create(options);
};


function SyncBailHook(args = []) {
    // 基本结构跟SyncHook都是一样的
    const hook = new Hook(args);
    hook.constructor = SyncBailHook;

    // 使用HookCodeFactory来创建最终的call函数
    hook.compile = COMPILE;

    return hook;
}

现在运行下代码,效果跟之前一样的,大功告成~

其他Hook的实现

到这里,tapable的源码架构和基本实现我们已经弄清楚了,但是本文只用了SyncHookSyncBailHook做例子,其他的,比如AsyncParallelHook并没有展开讲。因为AsyncParallelHook之类的其他Hook的实现思路跟本文是一样的,比如我们可以先实现一个独立的AsyncParallelHook类:

class AsyncParallelHook {
    constructor(args = []) {
        this._args = args;
        this.taps = [];
    }
    tapAsync(name, task) {
        this.taps.push(task);
    }
    callAsync(...args) {
        // 先取出最后传入的回调函数
        let finalCallback = args.pop();

        // 定义一个 i 变量和 done 函数,每次执行检测 i 值和队列长度,决定是否执行 callAsync 的最终回调函数
        let i = 0;
        let done = () => {
            if (++i === this.taps.length) {
                finalCallback();
            }
        };

        // 依次执行事件处理函数
        this.taps.forEach(task => task(...args, done));
    }
}

然后对他的callAsync函数进行抽象,将其抽象到代码工厂类里面,使用字符串拼接的方式动态构造出来就行了,整体思路跟前面是一样的。具体实现过程可以参考tapable源码:

Hook类源码

SyncHook类源码

SyncBailHook类源码

HookCodeFactory类源码

总结

本文可运行示例代码已经上传GitHub,大家拿下来一边玩一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/tapable-source-code

下面再对本文的思路进行一个总结:

  1. tapable的各种Hook其实都是基于发布订阅模式。
  2. 各个Hook自己独立实现其实也没有问题,但是因为都是发布订阅模式,会有大量重复代码,所以tapable进行了几次抽象。
  3. 第一次抽象是提取一个Hook基类,这个基类实现了初始化和事件注册等公共部分,至于每个Hookcall都不一样,需要自己实现。
  4. 第二次抽象是每个Hook在实现自己的call的时候,发现代码也有很多相似之处,所以提取了一个代码工厂,用来动态生成call的函数体。
  5. 总体来说,tapable的代码并不难,但是因为有两次抽象,整个代码架构显得不那么好读,经过本文的梳理后,应该会好很多了。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

参考资料

tapable用法介绍:https://segmentfault.com/a/1190000039418800

tapable源码地址:https://github.com/webpack/tapable

查看原文

赞 19 收藏 14 评论 2

蒋鹏飞 报名了系列讲座 · 2月25日

TypeScript从入门到实践 【2021 版】

> 预售课程,正在更新中,每周预计更新两到三节 前端进入工程化时代,JavaScript 技术框成功打入后端。高效、高质量的开发都更需要工具的支持。TypeScript 将类型系统引入 JavaScript 技术栈,承载着增强编辑器、静态检查等工具的作用,提升了模块化、框架化的开发体验,逐渐成为 JavaScript 技术栈的必学技能之一。 TypeScript 建立在弱类型高灵活度的 JavaScript 基础之上,引入类型系统的强制约束以高程序的自检能力和自解释能力,不仅是前端开发者进入中大型应用开发的桥梁,也为后端强类型语言开发者带来熟悉的语法体验,让后端开发者可以更快地进入前端开发。 ### 课程概要 本课程从类型化角度讲述了 TypeScript 诞生的必要性和必然性,然后从细节抽丝剥茧,以不断发现问题,解决问题的的思路带领学员逐步深入学习 TypeScript。主要知识点: 1. 类型系统的理论基础。这里包含类型基础知识、强弱类型的区别,以及通过正确运用静态类型检查和动态类型检查来提升代码的正确性和健壮性。 2. TypeScript 的主要语法,包括基本类型、函数、接口、类、泛型等类型相关的语法声明和实际应用,以及模块化的开发方式。 3. TypeScript 开发示例,通过短小易懂的示例演示如何应用 TypeScript 语法的同时,介绍相关语法的应用场景和在设计模式中的应用。 4. TypeScript 开发工具应用,包括编辑器、静态检查工具、编译器配置、构建工具及其配置等。 5. TypeScript 项目开发实践,前端以 Vue为例,后端在 Koa 为例,通过一个小型应用展示 TypeScript 在项目中的实际应用,为最佳开发实践提供有效参考。 ### 课程大纲 #### 第一部分 开篇 - 我为什么选择TypeScript? #### 第二部分 TypeScript 基础 ##### 环境搭建 编程语言和开发技术都需要实践性的学习方法,通过实践加深理解,增强记忆。本课程在讲授过程中会有大量的示例代码演示,完全基于这部分环境搭建过程建立起来的实验场。 - 环境搭建:搭建开发环境 - 环境搭建:编写你的第一个 TypeScript 程序 - 环境搭建:搭建ts-playgroud ##### 类型基础 通常 ES5 中大家相对熟悉的基础类型,引导学员从已知的模糊的类型概念,逐渐细化,明晰。理解类型的作用,类型检查的工作方式,学习类型声明的细节,完成从模糊类型到明确类型的思维转变。 - 类型基础:强/弱类型和静/动态类型检查 - 类型基础:认识类型 - 类型基础:声明变量和常量 - 声明函数类型:定义函数 - 声明函数类型:参数进阶 - 声明函数类型:函数重载 ##### 对象和接口 接口是一种抽象类型,在接口的实现者和使用者之间形成明确的契约。具体地说就是接口的实现者决定了接口的使用方式,而接口的使用者很清楚应该如何来使用它们。接口的基本概念是对对象类型的抽象。本节从基本的 TypeScript 接口语法讲起,讲解接口在应对不同需求时的细节上的变化,以及使用接口声明函数和接口继承等进阶话题。 - 对象和接口:声明接口 - 对象和接口:接口属性进阶 - 对象和接口:接口和函数 - 对象和接口:接口继承 ##### 类 类是 OOP 语法的核心元素,它不仅声明自己能做什么,还得真实地去做这些事情。从抽象层次来角度来看,类介于接口和具体对象之间,既包含类型抽象,又包含具体实现。类语法丰富,应用灵活。本节课程仔细介绍了类的语法细节,并通过大量的代码演示来帮助理解。 - 类:定义类 - 类:类和接口 - 类:构造函数 - 类:this引用和this类型 - 类:属性、方法和及其修饰符 - 类:从构造函数生成属性 - 类:类和静态成员 - 类:类的继承 - 类:抽象类 ##### 高级类型 类型系统并不只是一纸约定,类型系统中的各个元素之间本身也存在着一定的逻辑关系。高级类型部分通过讲解具体的高级类型语法,分析实际案例中的类型关系,声明基本类型,对类型进行计算推导,并最终建立符合安全需求的高级类型结构,实现对复杂数据类型灵活有效的描述。 - 高级类型:枚举 - 高级类型:泛型 - 高级类型:类型映射 - 高级类型:预置工具类型 #### 第三部分:应用实战 这部分类型设计了一个简单的 TODO 应用。说它简单,是因为这个应用贴近开发者的生活,功能相对单一,易于理解。但在这个简单的应用示例上,我们会应用到前后端分离模式、前端工程化、分析和设计等,最关键是我们在前后端开发技术栈中都使用 TypeScript 作为主要开发语言。 - 开发一个Todo应用vue? - Todo List 的需求和设计,前后端分离开发模式 - Todo List 接口设计和开发 (TS + Koa) - Todo List 的前端设计和开发 (TS + Vue + AntDesign - Todo List 的移动前端设计和开发 #### 第四部分:课程总结 - 总结回顾 ### 讲师介绍 ** 边城 | SF社区声望值前三;西南科技大学 | (计算机科学与技术) (MBA);目前在四川凯路威科技有限公司担任软件总工程师一职。** 从事软件开发 20 年,在软件分析、设计、架构、开发及软件开发技术研究和培训等方面有着非常丰富的经验,近年主要在研究 Web 前端技术、基于 .NET 的后端开发技术和相关软件架构。 ### 本课程技术栈 1. TypeScript@latest, 2. Node.js@12+,TypeScript 构建工具运行环境以及 JS Server Side 运行环境 3. VSCode@latest,最优秀的 TypeScript 编辑器之一

蒋鹏飞 发布了文章 · 2月23日

技术写作技巧分享:我是如何从写作小白成长为多平台优秀作者的?

我从事技术写作的时间其实不长,开始写作的时间就是我掘金账号注册的时间:

image-20210219170755430

到今天(2021年2月23日)也就是一年零一个月,这一年的收获是超过我的预期的:

  1. 产出博文四十多篇,总共数十万字
  2. 掘金优秀作者,掘金年度人气作者No.27
  3. 思否2020年度"Top Writer"万粉专栏作者
  4. 开源中国优秀源创作者,源创计划年度活跃博主 Top20

本文想对这个历程做一个回顾,并分享一下我总结的写作技巧以及推广策略。

为什么写作

在写作之前想清楚为什么写作非常重要!因为你最初的想法会决定你往哪个方向去写,写出的内容的质量怎么样。

我写作的原因很简单,就是我前端做了几年了,大部分时间都在写业务代码,技术上一直没有太大的突破,最多也就是换个框架,换个UI库,换来换去始终感觉似曾相识。为了不让几年工作经验成为“第一年工作经验的复制品”,我决定再深入,系统的学习下前端知识。所以对于我来说,写作是我的学习方法,我的首要目的是学习知识,写作带来的社区声望只是附带的,有了当然好,没有也没必要刻意去刷。

“为学习而写”与“为刷声望而写”

根据我的观察,社区上的作者写作目的主要分为两种:“为学习而写”与“为刷声望而写”。

大部分厉害的大佬其实都是“为学习而写”,就是他们看到什么好玩的,新奇的技术,去学习了,自然而然的总结出文章。或者觉得某个知识点大家很容易搞错,想输出自己的观点,帮大家避坑,就将自己的见解写成文章,这个过程作者虽然更多的是在输出内容,但是写作的过程其实也会强化作者自己的理解,其实也是一个学习方法。我个人认为“为学习而写”写出的文章才是正道,是社区良性发展的方向。

当然也有少部分作者想在短时间内获取更多关注而刻意的去迎合读者口味,也就是“为刷声望而写”。比较典型的一个例子就是,掘金曾经在某段时期被大量的面试题汇总占据。大家出去面试了回来分享下心得其实是好事,但是刻意的去搜集面试题,相似的内容发了一遍又一遍,里面的答案甚至还是错的,会导致社区越来越功利,低质量面试题霸版,高质量技术文章反而没机会展示,从而造成劣币驱逐良币的现象。我记得那会儿有个作者靠反复发面试题,短时间就刷了三四千掘力值,眼看就要到“优秀作者”了,结果被一个社区大佬怼了,然后就没怎么露面了。这样,前面刷的几千声望不是都白费了吗?后来掘金官方也整治了低质量的面试题文章,现在的情况已经好多了。

所以我说,写作前想清楚“为学习而写”与“为刷声望而写”很重要,如果是“为学习而写”,那就可以写出自己的心得体会,写出高质量文章,如果单纯是“为刷声望而写”,可能短期会有点收益,但是也有可能会被大佬怼,被官方整治,前功尽弃。

写什么

在这个“系统学习计划”开始之前,我其实没怎么写过技术文章,甚至都没怎么逛过技术社区。平时如果需要学习一个东西,比如学习React,那我会直接去它的官方网站,把它的文档全部读一遍,现在这些流行库的文档都写的很好,看一遍基本就能上手了。如果看完了还是不太知道怎么用,那就去公司看看有没有项目用过,公司没用过,就去GitHub上找找,然后抄抄改改就能上手了。这个过程一般也就几天,复杂的库最多也就一两周就能上手。使用的时候遇到问题就用Google搜,基本都会找到Stack Overflow上,答案拿过来一用就行。

前面几年我的工作模式基本都是这样的,这样应付工作也没啥问题,但是第一年是这样,第二年是这样,第n年还是这样。。。就成了“一年工作经验复用n年”,成了名副其实的“API工程师”,做项目没问题,问原理似曾相识,但是却说不太清楚。如果一直这样,技术就会一直原地踏步,在现单位很容易被替代,出去找工作也可能会四处碰壁,或者找来找去找到的始终跟当前的差不多,很难实现大的突破。

我感到,我碰到瓶颈了。我想突破这个瓶颈,但是我不知道怎么做!在没有具体方向的时候,就看看手上能做啥吧,从简单的,可见的开始做。于是,我决定,我要重头整理自己的知识框架,把那些只是似曾相似的技术,原理全部吃透,于是我从网上找了一份“前端知识架构图谱”,决定按照里面的提纲,全部重新学习一遍。只是我再次学习不能是简单的看看书,看看博客,看看视频就行了,这种事情我以前干过了,作为一个有几年工作经验的前端,我对自己有更高的要求:所有学过的知识点,必须自己全部写成文章进行巩固;所有框架的学习,必须学到原理或者源码层面

所以,“写什么”这个问题的答案已经有了:学习前端知识架构,将学习过程写成文章

怎么写

上面说了,我其实并没有什么写作经验,我最近一次写作是大学论文,再往前就是高中作文了,写作水平其实不咋地。但是技术写作跟普通作文不一样,一般不需要华丽的辞藻,更重要的是要把问题讲清楚,看技术文章的读者需要的是学习技术知识,而不是看风花雪月,所以技术文章的逻辑,层级递进,由浅入深,好理解其实更重要。我刚开始时也不知道怎么写,也是在不断写作工程中,一边写,一边总结,整体来说,我自己的文章其实都分了好几个阶段:

  1. 就是记个笔记
  2. 有自己理解的知识点解析
  3. 深入源码,探究原理
  4. 从工作中总结

就是记个笔记

从小学开始,老师就会让大家记笔记,大家应该都会,这也是最简单的切入点。我刚开始的时候,不会写文章,写的基本都是笔记,比如各种CSS居中方案,这就是我在其他地方学的,然后把他记录下来,也就是个笔记而已。对于“CSS居中”这种问题来说,面试问烂了,网上资料也是一大堆,这篇文章也没什么出彩的地方,所以关注的人不多。其实对于“笔记型”来说,获取关注少是很容易理解的,因为你写的东西是笔记,也就是说你也是从其他地方学来的,整个文章的思路其实也是人家的,如果自己记笔记的水平不高,可能写出来的效果还不如原文章。

有自己理解的知识点解析

在写了一些“笔记型”文章后,我发现效果不好,不仅仅是没什么人关注,甚至对自己帮助也不大。经常是写了没多久就忘了,需要的时候还要回过头来看看笔记,我开始意识到,这个现象的本质是,你写的东西是笔记,核心思想都是人家的,或者是自己东拼西凑的,整篇文章没有自己的逻辑,没有自己的见解。于是,我开始尝试在文章中加入自己的见解,当时正好组内有小伙伴对“JS原型链”理解的不是很透彻,网上虽然有很多类似文章,但是很多都是从表面来解释“原型链是什么”,画的图也很复杂,不是很好理解。于是我尝试自己写一篇原型链的文章,因为我知道他可以实现“面向对象”的特征,这是很多其他文章都没怎么提的,但却是设计者最初可能想要实现的效果,于是我类比Java的面向对象,从面向对象的角度讲述了原型链的作用以及他存在的意义,就是这个:轻松理解JS中的面向对象,顺便搞懂prototype和__proto__。这篇文章上了掘金首页推荐,最终获得了两百多赞,一万多阅读,这让我开始意识到,“有自己理解的知识点解析”在掘金可能更受欢迎。

在这之后,我开始有意识的在整理知识架构时加入自己的见解。那对于一个知识点,怎么产生自己的见解呢?这需要在学习时多问自己几个问题!比如,学习HTTPS时,除了跟大家一样搞清楚HTTPS的加解密流程,握手过程外,我问了自己一个问题:“HTTPS有没有可能被破解?假如我是个黑客,如果我想破解HTTPS,有哪些方法和途径?”带着这个问题,我从“破解HTTPS”的角度讲述了HTTPS的原理,这篇文章也上了推荐,获得了一百多赞和好几千阅读:RSA初探,聊聊怎么破解HTTPS

尝到点甜头后,我更加注意在学习中反问自己问题,加入自己理解了。有时候在学习别人的东西时,我发现了别人没发现的一些点,也可以从这个角度加入自己的独到见解,写成自己的文章,比如某视频课程在讲述JS的事件循环时说:“setImmediatesetTimeout先执行”。听到这句话,我敏锐的感觉不太对,因为我曾经遇到过setTimeoutsetImmediate先执行的情况,但是具体是啥情况我一时想不起来。于是我花了点时间把这个问题和原理彻底弄清楚了,并写成了自己的文章:setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop。这篇文章最终也获得了一百多赞,大几千阅读~

深入源码,探究原理

JS知识体系虽然庞大,但是终究是有限的,很快我就写了十几篇JS的文章,内容包含了内存管理,深浅拷贝,面向对象(原型链),this指向,事件循环,变量类型,作用域等等。这些已经囊括了JS的主要知识点,JS上我已经很难找到新的写文章的点了。

于是我的文章内容开始转向我使用的框架,这几年我主要使用的React技术栈。于是我准备重新整理学习React技术栈,当然不是学习他的用法了,毕竟我用了几年了,用法早就熟悉了,这次我要学的是他们的源码和原理。源码和原理相对于JS知识和框架使用方法来说要难得多,受众也小的多,对于读者来说也很难产生直接的收益。因为读者可能看个JS知识点,出去面试就能应付大部分的JS面试了,除了些大厂外,也不是每个公司面试都会问源码,而且这些受欢迎的开源库是各位大牛努力写作的成果,里面汇聚了各种JS的高级用法,各种高级编程思想和设计模式,所以即使我尽量写得深入浅出,层层递进,相较于其他文章来说仍然会显得更加晦涩难读。所以这类文章在掘金获得的赞和阅读并不可观,我大量的源码解析都只有三四十个赞,这里面还有一半左右是我厚脸求朋友同事们点的(这点我后面在讲推广的时候会说)。

对于作者来说,写源码类文章需要去读框架源码,也会很花时间。我写一个JS知识点的文章,因为东西都是我熟悉的,可能几天就搞定了,写完了还会有上百的赞。但是一个复杂框架的源码解析,比如Express.js,我需要一点点的去读,去调试源码,成文可能需要两三周,写完后可能仍然只有三四十个赞。从社区声望增长这个角度来说,性价比极低!但是我一直没有放弃这类文章,甚至现在成了我主要的写作方向。为什么?因为人总要突破自己的舒适区,探索未知的领域,最终才能学习到东西,获得成长!这其实回到了文章开头就提出的问题:“你为什么要写技术文章?”对于我来说,这是我学习的途径,所以如果这个过程我能够学到东西,能够感受到成长,我就会坚持去做,即使他在其他方面性价比很低!另外我的源码类文章虽然在掘金反响不是很好,但是在其他平台,比如思否,还可以,所以其实也是有回报的。

好了,说了这么多为什么要写源码解析,现在来谈谈怎么写源码解析。前面说了,在我从事技术写作之前,我基本不懂源码,是名副其实的“API工程师”,那会儿我也是一提到源码就心慌,完全不知道从何下手。后来我忐忑的打破自己的心理障碍,多次尝试之后找到了一个看源码的套路。其实再🐂的框架或库本质也是JS代码,所以我们可以用一种简单质朴的方法去读,这其实也是大家经常在用的方法。想象这样一个场景,你们公司一个运行很久的项目出了点问题,你领导让你去调查下。由于这个项目你之前没有参与,现在贸然叫你去解决BUG,你是不是要先反复复现问题,然后找到相关的代码块,调试这些代码并找到BUG原因,然后将它修复。看源码的时候我们完全可以用类似的思路去看,先缩小范围,只看这个库的核心代码。比如Koa.js核心用法其实只有这么点:

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

你就把它当成你现在需要接手的老项目,为了弄懂他的运行逻辑,看看这段代码里面他暴露了哪些API,然后一个一个去调试下就行了。就上面这几行代码而言,其实只有三个API:

  1. Koa
  2. app.use实例方法
  3. app.listen实例方法

花点时间去源码里面找到这三个API,并看看他们是怎么实现的,在看的时候,把主要逻辑剥离出来,自己实现一遍,同时把整个过程记录下来。等你把这三个API实现都看懂了,其实一篇源码解析的文章也就出来了,同时还可以产出一个迷你版Koa手写Koa.js源码

从工作中总结

其实很多公司都不是技术驱动的,技术只是实现业务的一个手段而已,这就造成很多公司的一个项目都是从另一个项目抄抄改改就能用,很多人(包括我)在这种环境下待久了,慢慢就成了“API工程师”,最熟悉的其实是CV大法。但是每个公司的业务其实在某方面都有自己一定的独到性,不然也活不下来,在实现这些比较复杂的业务时,有时候我们也会用一些比较有意思的方法,有时候我们可能花了很多时间去实现他,但是过后又慢慢淡忘了。其实对于这些有一定技术含量的工作,我们也可以总结下,然后写成文章,慢慢沉淀下来成为自己的技术。我就从工作中总结了三篇文章,有的反响还不错:

  1. 速度提高几百倍,记一次数据结构在实际工作中的运用
  2. 使用mono-repo实现跨项目组件共享
  3. 歪门邪道性能优化:魔改三方库源码,性能提高几十倍!

推广

有句俗话说:“酒香不怕巷子深”。但是这个并不适用于现在的互联网时代,互联网时代是信息爆炸的时代,如果没有适度的推广,即使你的内容很好,最终也会淹没在信息的洪流里面。好内容的推广对于社区,读者和作者来说其实是三赢的。

对于社区来说,如果有大量优质内容提供给读者,口碑就会很好,读者会愿意长期待在这个社区学习,并可能会主动推荐给朋友。所以很多社区的编辑很大一部分工作就是主动发掘好的内容,并推送给更多的用户。

对于读者来说,好内容的推广可以学习到更多东西,而不是整天被一些低质量内容霸屏。

对于作者来说,好内容的推广可以获得更多关注,更多的社区声望,激发创作热情,从而形成正向激励,产出更多高质量内容。

但是推广有一个很大的前提:推广的内容一定要是高质量的内容,不然会起反效果

所以我的技术写作,我也尝试了多种推广方式和渠道,不同的方式效果不一样,我用过的方式主要有:

  1. 各种QQ群,微信群分享
  2. 找朋友,同事帮忙点赞
  3. 找社区编辑帮忙推荐
  4. 多平台发布
  5. 文章相互引用
  6. 运营微信公众号

下面就这些详细讲述下:

推广的前提是高质量

在推广之前,一定要确保你推广的内容的质量,至少要是你用心写的,也许你现在只是一个初学者,写不出高深内容,但是你写的内容一定要是你用心写的,要让读者感受到你的诚意。如果只是简单的面试题拼凑,甚至里面的答案都是错的,你还拼命去推广,你推广的越多,只会让更多人知道你写的东西不好,没诚意,可能还会被很多人留言怼。就像开头提到的那个例子,如果一味的为了“刷声望”而去拼凑内容,大量推广,你声望可能会涨得很快,但是,同时也会让大量的人知道,你写的东西不行,没诚意,甚至可能被大佬怼到不敢露面。

QQ群,微信群分享

我开始写文章时喜欢写完了就分享到一些QQ群和微信群,但是效果并不好。经常是分享到一个几百人的群,过一会儿去看,阅读量涨了几百,但是赞一个没有。。。当然也可能是我早期的“笔记型”文章质量不高,所以获赞不多,比如前面提到过的各种CSS居中方案,我就分享到过很多群,最终有三千多阅读,但是赞只有三十来个。。。所以我现在已经基本不乱分享了,收益太低,还可能被当成打广告的遭嫌弃。

找朋友,同事帮忙点赞

这条主要是针对掘金平台的,因为掘金的赞多了可以升级,升到4级就是“优秀作者”,可以自动上首页。所以我在掘金发布后,会分享给关系好的同事和同学,因为关系很好,他们基本都会帮忙点个👍。但是这部分老铁人不多,总共也就十几个。

找社区编辑推荐

这其实是效果最好的一个推广渠道,可以联系社区编辑,将写好的文章链接发给他,编辑在审核后,觉得可以的会推荐到社区首页,这会大大提高曝光量。以掘金为例,一般我上首页推荐的文章,至少都会有十来个赞,阅读少说几百上千。加上前面朋友点的赞,我一篇文章最少会有三十来个赞,加上阅读量转换的掘力值,一篇文章至少会有四五十的掘力值。有一段时间,我就以这个为基准在那里算:我再写一百篇就可以升4级了,哈哈😃 当然如果出了爆品,某篇文章获得了成百上千的赞,会大大加速这个进程。

其他社区,比如思否,开源中国,找编辑推荐效果也是非常好的,他们有作者推荐群,可以联系编辑加群,有好的内容就可以发到群里求推荐。

多平台发布

中文社区其实还是挺多的,我最开始是在掘金写文章,但是粉丝最多的平台却是思否,个人粉丝将近两千,专栏粉丝一万多。所以你文章写好后,可以发布到多个平台,也许这个平台不火的文章在另一个平台却火了。目前对于我来说效果还不错的平台有:掘金,思否,开源中国和博客园,下面我就这几个平台的特点来细说下:

掘金

掘金最大的特色是等级制度,等级到4级可以解锁成就:掘金优秀作者,然后发布的文章可以自动上首页,可以大大提高曝光量。另外编辑也很负责,会主动寻找优质内容推荐到首页,所以如果你持续输出优质内容,篇篇被推荐也是有可能的。

思否

思否最大的特点是涨粉很快,因为新用户在注册思否时会推荐一些专栏和作者给他关注,如果你足够活跃,就可以进这个推荐列表。思否每年还会评定“Top Writer”,每年15人,因为名额少,所以比掘金的“优秀作者”还难点,如果被评上了“Top Writer”,会有一段时间的流量支持,涨粉更快,我评上后最多的一天涨粉上千。另外思否的技术团队也很负责,有什么问题在群里反馈了很快就能得到回答,有时候CEO还会亲自回复👍。

开源中国

开源中国流量也不错,如果被推荐上首页,至少会有一两千的阅读。另外在他的博客站点首页还有个“精彩博客”栏目,如果出现在这里,可以挂很长时间,下图中这篇文章:速度提高几百倍,记一次数据结构在实际工作中的运用是我1月6号发布的,到今天,2月23号,一个多月了还排在这个栏目第一,单篇阅读一直在涨,已经有4.7万了。

image-20210222165101368

而同样一篇文章我也发布到过掘金,只有三十来个赞,效果很一般,所以多平台发布还是有好处的,这个平台不火,另一个平台说不定就火了。

博客园

博客园最大的特色是在发布时可以自己选择上首页,当然如果你质量不好,还是可能会被编辑撤下来的,我以前就被撤下来过。因为可以自己决定上首页,所以博客园的首页刷新很快,一会儿就被淹没了,所以单篇阅读量不高,可能只有一两百。但是如果你能获得编辑的特别推荐,出现在这个位置,流量还是可以的:

image-20210222170001673

我有两篇获得过编辑推荐,最多的一篇有近万阅读,少的也有三四千,这个位置只能待一天,所以其实还是不错了。

另外,我还试过CSDN,知乎,腾讯云社区等,因为效果不是很好,已经没怎么运营了。大家早期时可以尽量多发布几个平台,然后看看哪个平台效果好就重点关注,效果不好的就可以放弃了,因为运营平台过多也会耗费大量精力,选性价比高的弄就行。

文章内相互引用

因为我写的东西成体系,所以一篇文章B可能会用到以前写的文章A的知识,那我就会在文章B里面引用文章A,这样读者可能就顺着去看文章A了。这样有一定的效果,有时候很久前写的文章会被点赞,就是这么来的。

运营微信公众号

我写了一段时间后,会有朋友给我留言,希望转载到微信公众号,这种情况遇到几次后,我就在想,我为啥不自己弄个公众号,于是我就开通了一个公众号进击的大前端。听说微信公众号还能赚钱,说不定我还能赚点外快,到目前为止确实有一点点收入:

  1. 获得赞赏收入7元,其中5元是我老婆给的
  2. 获得广告收入1.84元

这个收入还真是一点点😜,主要是因为我运营比较佛系,发的内容主要是原创,粉丝不多,新增粉丝主要是文章后面的广告和其他号主转发带来的。广告我也只放了文末广告,文中广告都没放,怕影响用户体验。

有一段时间我也想过要不要大力运营,每天转发更新内容,但是每天发内容需要寻找稿子,审核稿子,也需要不少时间。而我目前的主要精力在学习和原创内容上,就没弄了,先佛系运营着吧。

总结

本文总结和分享了我这一年从事技术写作的心得体会,对这一年进行了回顾,同时也希望给想往这方面发展的朋友提供一个参考。下面再对内容进行一个简短总结:

  1. 从事技术写作的目的最好是学习和分享,而不是单纯的刷声望。
  2. 写作内容可以是:

    1. 简单的学习笔记:因为是简单的记录别人的内容,效果可能不是很好
    2. 有自己理解的知识点解析:有自己见解,也有一定难度,但又不至于晦涩难懂,受众广,在社区容易受欢迎。
    3. 原理和源码解析:内容较难,受众略小,在社区不一定受欢迎,但是对于自己的成长非常有用。
    4. 从工作中总结:注意总结工作中有价值的技术内容,而不是做单纯的“API工程师”,在工作中完成技术沉淀,一举两得。
  3. 适度的推广是社区,读者和作者的三赢,但是推广的内容一定要是高质量的,不然可能会起反效果,一般推广手段有:

    1. 各种群分享:效果不好,经常是阅读量涨几百,赞一个没有
    2. 分享给朋友,同事:早期有用,可以保底有几个赞,但是数量毕竟有限。
    3. 社区编辑推荐:最有用的方式,可以大幅提高曝光量,但是质量一定要过关才行。
    4. 多平台发布:写了文章后可以尝试发到多个平台,也许这个平台不火的另一个平台火了。
    5. 文章内相互引用:有一点效果,可以让很久前写的文章仍然获得少量曝光。
    6. 运营微信公众号:据说能赚钱,但是我佛系运营,目前总收入不到10块。

最后感谢各位读者的阅读,点赞

感谢各位公众号号主的转发

感谢掘金,思否,开源中国,博客园等平台的大力支持

你们的支持一直是我持续创作的动力

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 15 收藏 4 评论 2

蒋鹏飞 发布了文章 · 2月19日

手写一个webpack,看看AST怎么用

本文开始我会围绕webpackbabel写一系列的工程化文章,这两个工具我虽然天天用,但是对他们的原理理解的其实不是很深入,写这些文章的过程其实也是我深入学习的过程。由于webpackbabel的体系太大,知识点众多,不可能一篇文章囊括所有知识点,目前我的计划是从简单入手,先实现一个最简单的可以运行的webpack,然后再看看plugin, loadertree shaking等功能。目前我计划会有这些文章:

  1. 手写最简webpack,也就是本文
  2. webpackplugin实现原理
  3. webpackloader实现原理
  4. webpacktree shaking实现原理
  5. webpackHMR实现原理
  6. babelast原理

所有文章都是原理或者源码解析,欢迎关注~

本文可运行代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

注意:本文主要讲webpack原理,在实现时并不严谨,而且只处理了importexportdefault情况,如果你想在生产环境使用,请自己添加其他情况的处理和边界判断

为什么要用webpack

笔者刚开始做前端时,其实不知道什么webpack,也不懂模块化,都是html里面直接写script,引入jquery直接干。所以如果一个页面的JS需要依赖jquerylodash,那html可能就长这样:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script data-original="https://unpkg.com/jquery@3.5.1"></script>
    <script data-original="https://unpkg.com/lodash@4.17.20"></script>
    <script data-original="./src/index.js"></script>
  </head>
  <body>
  </body>
</html>

这样写会导致几个问题:

  1. 单独看index.js不能清晰的找到他到底依赖哪些外部库
  2. script的顺序必须写正确,如果错了就会导致找不到依赖,直接报错
  3. 模块间通信困难,基本都靠往window上注入变量来暴露给外部
  4. 浏览器严格按照script标签来下载代码,有些没用到的代码也会下载下来
  5. 当前端规模变大,JS脚本会显得很杂乱,项目管理混乱

webpack的一个最基本的功能就是来解决上述的情况,允许在JS里面通过import或者require等关键字来显式申明依赖,可以引用第三方库,自己的JS代码间也可以相互引用,这样在实质上就实现了前端代码的模块化。由于历史问题,老版的JS并没有自己模块管理方案,所以社区提出了很多模块管理方案,比如ES2015importCommonJSrequire,另外还有AMDCMD等等。就目前我见到的情况来说,import因为已经成为ES2015标准,所以在客户端广泛使用,而requireNode.js的自带模块管理机制,也有很广泛的用途,而AMDCMD的使用已经很少见了。

但是webpack作为一个开放的模块化工具,他是支持ES6CommonJSAMD等多种标准的,不同的模块化标准有不同的解析方法,本文只会讲ES6标准的import方案,这也是客户端JS使用最多的方案。

简单例子

按照业界惯例,我也用hello world作为一个简单的例子,但是我将这句话拆成了几部分,放到了不同的文件里面。

先来建一个hello.js,只导出一个简单的字符串:

const hello = 'hello';

export default hello;

然后再来一个helloWorld.js,将helloworld拼成一句话,并导出拼接的这个方法:

import hello from './hello';

const world = 'world';

const helloWorld = () => `${hello} ${world}`;

export default helloWorld;

最后再来个index.js,将拼好的hello world插入到页面上去:

import helloWorld from "./helloWorld";

const helloWorldStr = helloWorld();

function component() {
  const element = document.createElement("div");

  element.innerHTML = helloWorldStr;

  return element;
}

document.body.appendChild(component());

现在如果你直接在html里面引用index.js是不能运行成功的,因为大部分浏览器都不支持import这种模块导入。而webpack就是来解决这个问题的,它会将我们模块化的代码转换成浏览器认识的普通JS来执行。

引入webpack

我们印象中webpack的配置很多,很麻烦,但那是因为我们需要开启的功能很多,如果只是解析转换import,配置起来非常简单。

  1. 先把依赖装上吧,这没什么好说的:

    // package.json
    {
      "devDependencies": {
        "webpack": "^5.4.0",
        "webpack-cli": "^4.2.0"
      },
    }
  2. 为了使用方便,再加个build脚本吧:

    // package.json
    {
      "scripts": {
        "build": "webpack"
      },
    }
  3. 最后再简单写下webpack的配置文件就好了:

    // webpack.config.js
    
    const path = require("path");
    
    module.exports = {
      mode: "development",
      devtool: 'source-map',
      entry: "./src/index.js",
      output: {
        filename: "main.js",
        path: path.resolve(__dirname, "dist"),
      },
    };

    这个配置文件里面其实只要指定了入口文件entry和编译后的输出文件目录output就可以正常工作了,这里这个配置的意思是让webpack./src/index.js开始编译,编译后的文件输出到dist/main.js这个文件里面。

    这个配置文件上还有两个配置modedevtool只是我用来方便调试编译后的代码的,mode指定用哪种模式编译,默认是production,会对代码进行压缩和混淆,不好读,所以我设置为development;而devtool是用来控制生成哪种粒度的source map,简单来说,想要更好调试,就要更好的,更清晰的source map,但是编译速度变慢;反之,想要编译速度快,就要选择粒度更粗,更不好读的source mapwebpack提供了很多可供选择的source map具体的可以看他的文档

  4. 然后就可以在dist下面建个index.html来引用编译后的代码了:

    // index.html
    
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
      </head>
      <body>
        <script data-original="main.js"></script>
      </body>
    </html>
  5. 运行下yarn build就会编译我们的代码,然后打开index.html就可以看到效果了。

    image-20210203154111168

深入原理

前面讲的这个例子很简单,一般也满足不了我们实际工程中的需求,但是对于我们理解原理却是一个很好的突破口,毕竟webpack这么庞大的一个体系,我们也不能一口吃个胖子,得一点一点来。

webpack把代码编译成了啥?

为了弄懂他的原理,我们可以直接从编译后的代码入手,先看看他长啥样子,有的朋友可能一提到去看源码,心理就没底,其实我以前也是这样的。但是完全没有必要惧怕,他编译后的代码浏览器能够执行,那肯定就是普通的JS代码,不会藏着这么黑科技。

下面是编译完的代码截图:

image-20210203155553091

虽然我们只有三个简单的JS文件,但是加上webpack自己的逻辑,编译后的文件还是有一百多行代码,所以即使我把具体逻辑折叠起来了,这个截图还是有点长,为了能够看清楚他的结构,我将它分成了4个部分,标记在了截图上,下面我们分别来看看这几个部分吧。

  1. 第一部分其实就是一个对象__webpack_modules__,这个对象里面有三个属性,属性名字是我们三个模块的文件路径,属性的值是一个函数,我们随便展开一个./src/helloWorld.js看下:

    image-20210203161613636

    我们发现这个代码内容跟我们自己写的helloWorld.js非常像:

    image-20210203161902647

    他只是在我们的代码前先调用了__webpack_require__.r__webpack_require__.d,这两个辅助函数我们在后面会看到。

    然后对我们的代码进行了一点修改,将我们的import关键字改成了__webpack_require__函数,并用一个变量_hello__WEBPACK_IMPORTED_MODULE_0__来接收了import进来的内容,后面引用的地方也改成了这个,其他跟这个无关的代码,比如const world = 'world';还是保持原样的。

    这个__webpack_modules__对象存了所有的模块代码,其实对于模块代码的保存,在不同版本的webpack里面实现的方式并不一样,我这个版本是5.4.0,在4.x的版本里面好像是作为数组存下来,然后在最外层的立即执行函数里面以参数的形式传进来的。但是不管是哪种方式,都只是转换然后保存一下模块代码而已。

  2. 第二块代码的核心是__webpack_require__,这个代码展开,瞬间给了我一种熟悉感:

    image-20210203162542359

    来看一下这个流程吧:

    1. 先定义一个变量__webpack_module_cache__作为加载了的模块的缓存
    2. __webpack_require__其实就是用来加载模块的
    3. 加载模块时,先检查缓存中有没有,如果有,就直接返回缓存
    4. 如果缓存没有,就从__webpack_modules__将对应的模块取出来执行
    5. __webpack_modules__就是上面第一块代码里的那个对象,取出的模块其实就是我们自己写的代码,取出执行的也是我们每个模块的代码
    6. 每个模块执行除了执行我们的逻辑外,还会将export的内容添加到module.exports上,这就是前面说的__webpack_require__.d辅助方法的作用。添加到module.exports上其实就是添加到了__webpack_module_cache__缓存上,后面再引用这个模块就直接从缓存拿了。

    这个流程我太熟悉了,因为他简直跟Node.jsCommonJS实现思路一模一样,具体的可以看我之前写的这篇文章:深入Node.js的模块加载机制,手写require函数

  3. 第三块代码其实就是我们前面看到过的几个辅助函数的定义,具体干啥的,其实他的注释已经写了:

    1. __webpack_require__.d:核心其实是Object.defineProperty,主要是用来将我们模块导出的内容添加到全局的__webpack_module_cache__缓存上。

      image-20210203164427116

    2. __webpack_require__.o:其实就是Object.prototype.hasOwnProperty的一个简写而已。

      image-20210203164450385

    3. __webpack_require__.r:这个方法就是给每个模块添加一个属性__esModule,来表明他是一个ES6的模块。

      image-20210203164658054

    4. 第四块就一行代码,调用__webpack_require__加载入口模块,启动执行。

这样我们将代码分成了4块,每块的作用都搞清楚,其实webpack干的事情就清晰了:

  1. import这种浏览器不认识的关键字替换成了__webpack_require__函数调用。
  2. __webpack_require__在实现时采用了类似CommonJS的模块思想。
  3. 一个文件就是一个模块,对应模块缓存上的一个对象。
  4. 当模块代码执行时,会将export的内容添加到这个模块对象上。
  5. 当再次引用一个以前引用过的模块时,会直接从缓存上读取模块。

自己实现一个webpack

现在webpack到底干了什么事情我们已经清楚了,接下来我们就可以自己动手实现一个了。根据前面最终生成的代码结果,我们要实现的代码其实主要分两块:

  1. 遍历所有模块,将每个模块代码读取出来,替换掉importexport关键字,放到__webpack_modules__对象上。
  2. 整个代码里面除了__webpack_modules__和最后启动的入口是变化的,其他代码,像__webpack_require____webpack_require__.r这些方法其实都是固定的,整个代码结构也是固定的,所以完全可以先定义好一个模板。

使用AST解析代码

由于我们需要将import这种代码转换成浏览器能识别的普通JS代码,所以我们首先要能够将代码解析出来。在解析代码的时候,可以将它读出来当成字符串替换,也可以使用更专业的AST来解析。AST全称叫Abstract Syntax Trees,也就是抽象语法树,是一个将代码用树来表示的数据结构,一个代码可以转换成ASTAST又可以转换成代码,而我们熟知的babel其实就可以做这个工作。要生成AST很复杂,涉及到编译原理,但是如果仅仅拿来用就比较简单了,本文就先不涉及复杂的编译原理,而是直接将babel生成好的AST拿来使用。

注意:webpack源码解析AST并不是使用的babel,而是使用的acornwebpack自己实现了一个JavascriptParser类,这个类里面用到了acorn。本文写作时采用了babel,这也是一个大家更熟悉的工具

比如我先将入口文件读出来,然后用babel转换成AST可以直接这样写:

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

const config = require("../webpack.config"); // 引入配置文件

// 读取入口文件
const fileContent = fs.readFileSync(config.entry, "utf-8");

// 使用babel parser解析AST
const ast = parser.parse(fileContent, { sourceType: "module" });

console.log(ast);   // 把ast打印出来看看

上面代码可以将生成好的ast打印在控制台:

image-20210207153459699

这虽然是一个完整的AST,但是看起来并不清晰,关键数据其实是body字段,这里的body也只是展示了类型名字。所以照着这个写代码其实不好写,这里推荐一个在线工具https://astexplorer.net/,可以很清楚的看到每个节点的内容:

image-20210207154116026

从这个解析出来的AST我们可以看到,body主要有4块代码:

  1. ImportDeclaration:就是第一行的import定义
  2. VariableDeclaration:第三行的一个变量申明
  3. FunctionDeclaration:第五行的一个函数定义
  4. ExpressionStatement:第十三行的一个普通语句

你如果把每个节点展开,会发现他们下面又嵌套了很多其他节点,比如第三行的VariableDeclaration展开后,其实还有个函数调用helloWorld()

image-20210207154741847

使用traverse遍历AST

对于这样一个生成好的AST,我们可以使用@babel/traverse来对他进行遍历和操作,比如我想拿到ImportDeclaration进行操作,就直接这样写:

// 使用babel traverse来遍历ast上的节点
traverse(ast, {
  ImportDeclaration(path) {
    console.log(path.node);
  },
});

上面代码可以拿到所有的import语句:

image-20210207162114290

import转换为函数调用

前面我们说了,我们的目标是将ES6的import

import helloWorld from "./helloWorld";

转换成普通浏览器能识别的函数调用:

var _helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");

为了实现这个功能,我们还需要引入@babel/types,这个库可以帮我们创建新的AST节点,所以这个转换代码写出来就是这样:

const t = require("@babel/types");

// 使用babel traverse来遍历ast上的节点
traverse(ast, {
  ImportDeclaration(p) {
    // 获取被import的文件
    const importFile = p.node.source.value;

    // 获取文件路径
    let importFilePath = path.join(path.dirname(config.entry), importFile);
    importFilePath = `./${importFilePath}.js`;

    // 构建一个变量定义的AST节点
    const variableDeclaration = t.variableDeclaration("var", [
      t.variableDeclarator(
        t.identifier(
          `__${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__`
        ),
        t.callExpression(t.identifier("__webpack_require__"), [
          t.stringLiteral(importFilePath),
        ])
      ),
    ]);

    // 将当前节点替换为变量定义节点
    p.replaceWith(variableDeclaration);
  },
});

上面这段代码我们用了很多@babel/types下面的API,比如t.variableDeclarationt.variableDeclarator,这些都是用来创建对应的节点的,具体的API可以看这里。注意这个代码里面我有很多写死的地方,比如importFilePath生成逻辑,还应该处理多种后缀名的,还有最终生成的变量名_${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__,最后的数字我也是直接写了0,按理来说应该是根据不同的import顺序来生成的,但是本文主要讲webpack的原理,这些细节上我就没花过多时间了。

上面的代码其实是修改了我们的AST,修改后的AST可以用@babel/generator又转换为代码:

const generate  = require('@babel/generator').default;

const newCode = generate(ast).code;
console.log(newCode);

这个打印结果是:

image-20210207172310114

可以看到这个结果里面import helloWorld from "./helloWorld";已经被转换为var __helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");

替换import进来的变量

前面我们将import语句替换成了一个变量定义,变量名字也改为了__helloWorld__WEBPACK_IMPORTED_MODULE_0__,自然要将调用的地方也改了。为了更好的管理,我们将AST遍历,操作以及最后的生成新代码都封装成一个函数吧。

function parseFile(file) {
  // 读取入口文件
  const fileContent = fs.readFileSync(file, "utf-8");

  // 使用babel parser解析AST
  const ast = parser.parse(fileContent, { sourceType: "module" });

  let importFilePath = "";

  // 使用babel traverse来遍历ast上的节点
  traverse(ast, {
    ImportDeclaration(p) {
      // 跟之前一样的
    },
  });

  const newCode = generate(ast).code;

  // 返回一个包含必要信息的新对象
  return {
    file,
    dependcies: [importFilePath],
    code: newCode,
  };
}

然后启动执行的时候就可以调这个函数了

parseFile(config.entry);

拿到的结果跟之前的差不多:

image-20210207173744463

好了,现在需要将使用import的地方也替换了,因为我们已经知道了这个地方是将它作为函数调用的,也就是要将

const helloWorldStr = helloWorld();

转为这个样子:

const helloWorldStr = (0,_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default)();

这行代码的效果其实跟_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default()是一样的,为啥在前面包个(0, ),我也不知道,有知道的大佬告诉下我呗。

所以我们在traverse里面加一个CallExpression

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟前面的差不多,省略了
    },
    CallExpression(p) {
      // 如果调用的是import进来的函数
      if (p.node.callee.name === importVarName) {
        // 就将它替换为转换后的函数名字
        p.node.callee.name = `${importCovertVarName}.default`;
      }
    },
  });

这样转换后,我们再重新生成一下代码,已经像那么个样子了:

image-20210207175649607

递归解析多个文件

现在我们有了一个parseFile方法来解析处理入口文件,但是我们的文件其实不止一个,我们应该依据模块的依赖关系,递归的将所有的模块都解析了。要实现递归解析也不复杂,因为前面的parseFile的依赖dependcies已经返回了:

  1. 我们创建一个数组存放文件的解析结果,初始状态下他只有入口文件的解析结果
  2. 根据入口文件的解析结果,可以拿到入口文件的依赖
  3. 解析所有的依赖,将结果继续加到解析结果数组里面
  4. 一直循环这个解析结果数组,将里面的依赖文件解析完
  5. 最后将解析结果数组返回就行

写成代码就是这样:

function parseFiles(entryFile) {
  const entryRes = parseFile(entryFile); // 解析入口文件
  const results = [entryRes]; // 将解析结果放入一个数组

  // 循环结果数组,将它的依赖全部拿出来解析
  for (const res of results) {
    const dependencies = res.dependencies;
    dependencies.map((dependency) => {
      if (dependency) {
        const ast = parseFile(dependency);
        results.push(ast);
      }
    });
  }

  return results;
}

然后就可以调用这个方法解析所有文件了:

const allAst = parseFiles(config.entry);
console.log(allAst);

看看解析结果吧:

image-20210208152330212

这个结果其实跟我们最终需要生成的__webpack_modules__已经很像了,但是还有两块没有处理:

  1. 一个是import进来的内容作为变量使用,比如

    import hello from './hello';
    
    const world = 'world';
    
    const helloWorld = () => `${hello} ${world}`;
  2. 另一个就是export语句还没处理

替换import进来的变量(作为变量调用)

前面我们已经用CallExpression处理过作为函数使用的import变量了,现在要处理作为变量使用的其实用Identifier处理下就行了,处理逻辑跟之前的CallExpression差不多:

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟以前一样的
    },
    CallExpression(p) {
            // 跟以前一样的
    },
    Identifier(p) {
      // 如果调用的是import进来的变量
      if (p.node.name === importVarName) {
        // 就将它替换为转换后的变量名字
        p.node.name = `${importCovertVarName}.default`;
      }
    },
  });

现在再运行下,import进来的变量名字已经变掉了:

image-20210208153942630

替换export语句

从我们需要生成的结果来看,export需要进行两个处理:

  1. 如果一个文件有export default,需要添加一个__webpack_require__.d的辅助方法调用,内容都是固定的,加上就行。
  2. export语句转换为普通的变量定义。

对应生成结果上的这两个:

image-20210208154959592

要处理export语句,在遍历ast的时候添加ExportDefaultDeclaration就行了:

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟以前一样的
    },
    CallExpression(p) {
            // 跟以前一样的
    },
    Identifier(p) {
      // 跟以前一样的
    },
    ExportDefaultDeclaration(p) {
      hasExport = true; // 先标记是否有export

      // 跟前面import类似的,创建一个变量定义节点
      const variableDeclaration = t.variableDeclaration("const", [
        t.variableDeclarator(
          t.identifier("__WEBPACK_DEFAULT_EXPORT__"),
          t.identifier(p.node.declaration.name)
        ),
      ]);

      // 将当前节点替换为变量定义节点
      p.replaceWith(variableDeclaration);
    },
  });

然后再运行下就可以看到export语句被替换了:

image-20210208160244276

然后就是根据hasExport变量判断在AST转换为代码的时候要不要加__webpack_require__.d辅助函数:

const EXPORT_DEFAULT_FUN = `
__webpack_require__.d(__webpack_exports__, {
   "default": () => (__WEBPACK_DEFAULT_EXPORT__)
});\n
`;

function parseFile(file) {
  // 省略其他代码
  // ......
  
  let newCode = generate(ast).code;

  if (hasExport) {
    newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`;
  }
}

最后生成的代码里面export也就处理好了:

image-20210208161030554

__webpack_require__.r的调用添上吧

前面说了,最终生成的代码,每个模块前面都有个__webpack_require__.r的调用

image-20210208161321401

这个只是拿来给模块添加一个__esModule标记的,我们也给他加上吧,直接在前面export辅助方法后面加点代码就行了:

const ESMODULE_TAG_FUN = `
__webpack_require__.r(__webpack_exports__);\n
`;

function parseFile(file) {
  // 省略其他代码
  // ......
  
  let newCode = generate(ast).code;

  if (hasExport) {
    newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`;
  }
  
  // 下面添加模块标记代码
  newCode = `${ESMODULE_TAG_FUN} ${newCode}`;
}

再运行下看看,这个代码也加上了:

image-20210208161721369

创建代码模板

到现在,最难的一块,模块代码的解析和转换我们其实已经完成了。下面要做的工作就比较简单了,因为最终生成的代码里面,各种辅助方法都是固定的,动态的部分就是前面解析的模块和入口文件。所以我们可以创建一个这样的模板,将动态的部分标记出来就行,其他不变的部分写死。这个模板文件的处理,你可以将它读进来作为字符串处理,也可以用模板引擎,我这里采用ejs模板引擎:

// 模板文件,直接从webpack生成结果抄过来,改改就行
/******/ (() => { // webpackBootstrap
/******/     "use strict";
// 需要替换的__TO_REPLACE_WEBPACK_MODULES__
/******/     var __webpack_modules__ = ({
                <% __TO_REPLACE_WEBPACK_MODULES__.map(item => { %>
                    '<%- item.file %>' : 
                    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
                        <%- item.code %>
                    }),
                <% }) %>
            });
// 省略中间的辅助方法
    /************************************************************************/
    /******/     // startup
    /******/     // Load entry module
// 需要替换的__TO_REPLACE_WEBPACK_ENTRY
    /******/     __webpack_require__('<%- __TO_REPLACE_WEBPACK_ENTRY__ %>');
    /******/     // This entry module used 'exports' so it can't be inlined
    /******/ })()
    ;
    //# sourceMappingURL=main.js.map

生成最终的代码

生成最终代码的思路就是:

  1. 模板里面用__TO_REPLACE_WEBPACK_MODULES__来生成最终的__webpack_modules__
  2. 模板里面用__TO_REPLACE_WEBPACK_ENTRY__来替代动态的入口文件
  3. webpack代码里面使用前面生成好的AST数组来替换模板的__TO_REPLACE_WEBPACK_MODULES__
  4. webpack代码里面使用前面拿到的入口文件来替代模板的__TO_REPLACE_WEBPACK_ENTRY__
  5. 使用ejs来生成最终的代码

所以代码就是:

// 使用ejs将上面解析好的ast传递给模板
// 返回最终生成的代码
function generateCode(allAst, entry) {
  const temlateFile = fs.readFileSync(
    path.join(__dirname, "./template.js"),
    "utf-8"
  );

  const codes = ejs.render(temlateFile, {
    __TO_REPLACE_WEBPACK_MODULES__: allAst,
    __TO_REPLACE_WEBPACK_ENTRY__: entry,
  });

  return codes;
}

大功告成

最后将ejs生成好的代码写入配置的输出路径就行了:

const codes = generateCode(allAst, config.entry);

fs.writeFileSync(path.join(config.output.path, config.output.filename), codes);

然后就可以使用我们自己的webpack来编译代码,最后就可以像之前那样打开我们的html看看效果了:

image-20210218160539306

总结

本文使用简单质朴的方式讲述了webpack的基本原理,并自己手写实现了一个基本的支持importexportdefaultwebpack

本文可运行代码已经上传GitHub,大家可以拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

下面再就本文的要点进行下总结:

  1. webpack最基本的功能其实是将JS的高级模块化语句,importrequire之类的转换为浏览器能认识的普通函数调用语句。
  2. 要进行语言代码的转换,我们需要对代码进行解析。
  3. 常用的解析手段是AST,也就是将代码转换为抽象语法树
  4. AST是一个描述代码结构的树形数据结构,代码可以转换为ASTAST也可以转换为代码。
  5. babel可以将代码转换为AST,但是webpack官方并没有使用babel,而是基于acorn自己实现了一个JavascriptParser
  6. 本文从webpack构建的结果入手,也使用AST自己生成了一个类似的代码。
  7. webpack最终生成的代码其实分为动态和固定的两部分,我们将固定的部分写入一个模板,动态的部分在模板里面使用ejs占位。
  8. 生成代码动态部分需要借助babel来生成AST,并对其进行修改,最后再使用babel将其生成新的代码。
  9. 在生成AST时,我们从配置的入口文件开始,递归的解析所有文件。即解析入口文件的时候,将它的依赖记录下来,入口文件解析完后就去解析他的依赖文件,在解析他的依赖文件时,将依赖的依赖也记录下来,后面继续解析。重复这种步骤,直到所有依赖解析完。
  10. 动态代码生成好后,使用ejs将其写入模板,以生成最终的代码。
  11. 如果要支持require或者AMD,其实思路是类似的,最终生成的代码也是差不多的,主要的差别在AST解析那一块。

参考资料

  1. babel操作AST文档
  2. webpack源码
  3. webpack官方文档

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 55 收藏 45 评论 10

蒋鹏飞 赞了文章 · 2月4日

若川的2020年度总结,水波不兴

你好,我是若川,微信搜索「若川视野」关注我,专注前端技术分享。欢迎加我微信ruochuan12,加群交流学习。
文章首发于我的公众号「若川视野」《若川的2020年度总结,水波不兴》

前言

2014年开始,每一年都会写年度总结,坚持了6个年头。


回顾2014,约定2015(QQ空间日志)
2015年总结,淡化旧标签,无惧未来(QQ空间日志)
2016年度总结,毕业工作
2017年度总结,一如既往
2018年度总结,平淡无奇
2019年度总结,波澜不惊
2020年度总结,水波不兴(本文)


如今第7年了,最近总是想着2020年度总结的点点滴滴,思考这一年要写点什么不一样的,可是思前想后觉得这一年好像也没有什么不一样啊。2019年度总结文章中就写了2020年度总结的标题可能取名为「水波不兴」。内心深处就感觉水波不兴不错。可能是内心深处有个湖,湖面平静,有时会激起水花和波浪,但终究会恢复平静,也就是说平平淡淡是常态。年度总结往年基本是元旦3天假期就动笔了。


说起跨年和元旦,回忆起这几年的最后一天。
把时间拨回到2016年12月31日,那时同行四人去了良渚博物馆。
把时间拨回到2017年12月31日,那时几个在杭州的大学同学一起聚餐、K歌。
把时间拨回到2018年12月31日,几个同事一起去西湖边跨年,最后同事开车把我们送回。之后听到朋友说他们去西湖边跨年,人太多打不到车,最后凌晨四五点才回去,说再也不去西湖边跨年了。
时间再拨回到2019年12月31日,有了以往的“经验”,跨年的气息也没有那么浓厚,就自己在住处跨过了一年。第二天元旦,朋友开车带我去塘栖古镇逛了逛,开启了2020年。

杭州塘栖古镇夕阳西下,开启2020年

记得远在2019年12月份,那时我在微博上看到武汉发生不明肺炎。2020年,1月20日回家过春节时,看到各种群里有讨论要口罩,当时没想那么多也来不及买口罩,于是没有戴口罩回到了家。谁也不曾想,疫情会影响全世界
2020年12月31日,也是在住处跨过了一年。不平凡的一年过去了。

回想这几年,感叹时光飞逝,每一年都过得普普通通。远不及朋友圈各种大佬的一年。

个人是比较建议读者朋友们都写年度总结的,不一定要发布出来,给自己看也不错,或者给未来的自己看兴许也是一种回顾。非常欣慰有读者朋友特意到我的博客看有没有写年度总结,也有读者朋友在公众号留言说看完了我往年的年度总结,想看我的2020年的。可如今写出来了,怕是要对不住他们如此期待了。


2016年学习了一门年度计划的课程,提到人生的8个方面,分别是身体健康、财务理财、人际社群、工作事业、家庭生活、学习成长、体验突破、休闲放松。觉得这8方面还是挺合理的,于是从2016年度总结开始,都是按这8个方面来写年度总结。另外作为读者朋友的你也可以搜索微信小程序:滴答目标九宫格,看到的就是这八方面。

滴答目标九宫格图

,也可以看邹小强老师的这篇文章《小工具:随时都可以看到漂亮的目标板》

身体 · 健康

这一年,基本是走路上下班,姑且算是一种锻炼吧。
这一年,视力又下降了。在2018年度总结中写过的一句话引用过来同样适用。正印证了那句话:真的要少玩手机电脑了,眼睛越来越不好了,打开支付宝都看不到钱了。
这一年,没有难以入睡的记录。而2019年记录有12次辗转反侧,难以入睡。
身体健康重要性大家都知道,但往往是大部分作为年轻人的我们存在不良作息习惯和饮食习惯等,长此以往可能会导致身体一些问题。而身体健康、财务理财、人际社群可以看成是人生基石三要素。

财务 · 理财

年度总结里一直说理财,实际上很少理财。看到朋友圈同龄人炒股副业一年都赚了30w+,实名羡慕啊。不过仔细一想,别人肯定也是积累了很久和付出了很大的努力。看似云淡风轻,但事实上背后的过程我们没有看到。看到别人年度总结中的一句话:投资不追求暴利,年化10%~20%即可,相信复利的力量,关键在于坚持,10年内投资只是副业,10年后希望可以靠投资实现财务自由。

人际 · 社群

这一年,线下见过面的人屈指可数,总共见过两个大学校友,两个前端小伙伴(我微信群里的)。工作后除了公司同事,其他朋友线下见面,基本都是以一年为单位,一年到头线下见面的人真的很少。真正比较长期聊过天的人数也是很少。正所谓:越长大,越孤独

工作 · 事业

我的年度总结很少写公司工作方面。本职工作就是某不知名小公司的一名小前端开发工程师,负责小程序和网站开发等。
这一年,在工作方面,由于公司变动,我担任了前端开发的面试官,负责给公司招人。记得2017年第一次面试别人时,自己都是很紧张的。当初刚毕业时求职面试,如今身份转换,时间真快。
wakatime2020年使用Vscode编码时间统计,总共1572小时,和2019年相比基本持平。平均每天5个多小时在使用编辑器VSCode,其中2月13日最多,竟然11小时16分钟,记得这一天在家远程办公工作内容很多。花在自己博客的时间为45小时,相比2019年107小时有所下降。这里统计的是实际上聚焦使用VSCode的时间,应该还算是多的。在此放下统计地址https://wakatime.com/a-look-back-at-2020,方便不知道地址的读者朋友访问使用wakatime。

若川的wakatime 2020统计

按照以往的惯例,顺带贴下公司代码提交记录和个人github代码提交记录。

公司gitlab的代码提交记录

github的代码提交记录

技术自媒体,慢慢打造个人品牌

微信:再小的个体,也有自己的品牌。我从2016年度总结起,就一直写了「慢慢打造个人品牌」,真正有所收益时是2020年。

公众号 8w+ 阅读

这一年,比较佛系的运营了公众号「若川视野」,有了工作之外的一些收益。清博大数据和新榜年度报告中都显示公众号全年累计8w+阅读,相比2019年增加2183.73%,虽然可能不是那么准确,但应该也相差不大。

清博大数据统计

年初1月11日时,接了公众号第一次广告300元,但后续基本没有持续更新,比较佛系,接的广告也比较少。运营公众号其实是非常耗时耗力的。即使是转载文章,每天基本都要花上一小时左右选文章发文章。如果是自己写原创文章,基本每篇文章都需要10小时左右,甚至更多。作为一名互联网打工人,时间可是稀缺资源啊。

真正醒悟打算不那么佛系,工作日都更新时,已经是11月份了。为什么醒悟了,当时考虑有两点:1、公众号对于内容创作者非常有利,同时变现能力非常强,2、相信复利的力量探寻更多可能。在微信公众号平台上,声明过原创的文章,别人在公众号发布必须取得授权才能转载发布,而其他平台随意转载抄袭现象很常见。也许在N年后,运营公众号的副业收入能超过主业。虽然我的公众号粉丝数还很少,但也能接上一些广告,有了一些收益,也可以给我的读者朋友们谋些福利。比如逢年过节发些红包或者送些书籍,不知不觉在2020年,两个微信号竟然分别发出了3306.30元2469.30元,虽然分摊后读者朋友可能没收到多少,但我发现发了这么多红包时是惊讶的,因为相比2019年我发出的红包是1007.14元,而且我2020年的广告收入也很少,远不及一些大佬。在此特别感谢支持我的读者朋友们,感谢合作投放过广告的广告主(主要是开课吧、拉勾教育、珠峰教育)

说起运营公众号可以追溯到2013年,「若川视野」前身是我开通的社团的公众号,那时微信刚推出「微信公众号」一年左右。那时微信都很少使用,运营公众号,虽然关注人数少,但公众号也少,阅读量相对高。后来就业指导课上,老师要求我们每人写份简历,我写了一份「新媒体运营」的简历,竟然被老师表扬了,被老师流传至今,发给学弟学妹们参考,学弟学妹们不认识我,可能以为我在做「新媒体运营」相关工作。谁能想到多年以后的我,依旧会走上了新媒体(公众号)运营这条路。

有时会想是不是我早点运营起公众号,成果会比现在好呢,也许会的。但反思一下,公众号运营是要长期持续给粉丝提供价值,才能够持续长久正向循环。而长期持续提供价值,是需要作者本身有价值可以提供,需要多输入提升自己才能有价值的多输出。我同时也清楚地意识到:过早频繁的接广告无异于杀鸡取卵涸泽而渔,我需要的是多创作出优质的内容,好好运营,先做增量,稳住粉丝增长和阅读量增长。粉丝对公众号作者的内容和作者认可后,不会因为公众号接些广告而取消关注,毕竟粉丝因公众号内容受益,也知道做公众号不容易,也就是人们常说的利他共赢。目前阶段接多了广告,感觉有点愧对读者朋友的关注。

这一年,清明节假期,开通了第二个微信号,截止到目前有一千多微信好友。两个微信号累计3500+好友。也有6个微信交流群,共计一千多小伙伴,相比其他公众号号主来说算很少了。相比2019年,一个微信交流群来说有所进步。现如今是微信8.0了,支持一万个微信好友可看朋友圈,也不知道猴年马月会加满一万个微信好友,不过期待这一天早日到来。

运营公众号以来,也连接到了一些非常优秀的公众号号主和许多非常优秀的人。偶尔有人加我微信或者在他们的文章中提到,说我的《学习源码整体架构系列》对TA帮助很大,写作最开心的莫过于有很多人肯定和支持。甚至有的人说找到了20K的前端开发工作,为了表示感谢,一定要寄来一箱家里种的猕猴桃。而我自己觉得并没有帮到他们什么忙,不过还是很欣慰能得到大家的肯定和支持。后来想想这样不对啊,以后都寄来东西给我,而我不能提供相应的帮助,那就不好了。一直以来私聊我答疑解惑时,读者朋友发给我的红包都不收,另外也不收读者朋友寄给我的东西。

知乎 63w+ 阅读

这一年,知乎「若川」粉丝比去年多了2000+,现在是8377,阅读量比2019年增长了63w+,2019年时还是4w+阅读。主要有几篇回答被知乎推荐了。最高的一篇突破了31w+阅读量,如下图所示。虽然我觉得回答的也不是很好。也许是2019年写了《学习源码整体架构系列》厚积薄发的表现。

知乎回答阅读统计

其中高赞的两篇回答,也同步发表在公众号,不过知乎上是最新版本。
若川知乎问答:2年前端经验,做的项目没什么技术含量,怎么办?
若川知乎高赞:有哪些必看的 JS 库?
顺便再放下我的《学习源码整体架构系列》链接,koa源码redux源码是2020年写的。2019年下半年写了6篇,2020年只写了2篇,2021年会不会继续写下去是个谜。
1.学习 jQuery 源码整体架构,打造属于自己的 js 类库
2.学习 underscore 源码整体架构,打造属于自己的函数式编程类库
3.学习 lodash 源码整体架构,打造属于自己的函数式编程类库
4.学习 sentry 源码整体架构,打造属于自己的前端异常监控SDK
5.学习 vuex 源码整体架构,打造属于自己的状态管理库
6.学习 axios 源码整体架构,打造属于自己的请求库
7.学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理
8.学习 redux 源码整体架构,深入理解 redux 及其中间件原理

其他

这一年,2017年时就开通了免费的知识星球「前端视野 · 若川」的人数也陆陆续续有增长,但更新少了,主要时间和精力放在了公众号更新上。
这一年,在语雀平台发布的koa源码文章,被选为「语雀精选」,比较难得。

koa源码语雀精选标识

这一年,在掘金平台上只发了3篇文章,相比2019年多了1000+关注,阅读量却多了近10w+,现在累计14w+。

家庭 · 生活

这一年,在家待的时间是近年来最长。由于疫情,从1月20日放假到3月14日返杭,将近两个月的时间在家,远程办公。春节假期很长一段时间都是我妈做饭,我们不用操心。后来我妈上班去了,就是我们自己做饭了。
这一年,国庆和中秋一起,放假在家八天,国庆归来工作时,总感觉像是做了一场梦,梦回到家里,在家里的时光总是那么短暂。
这一年,给我弟买了一台新的笔记本电脑。
这一年,清晰的记得父亲节那天,和老爸聊天。老爸提起身在体制内堂哥的种种好处,让我羡慕不已。
这一年,「相亲相爱一家人」微信群用得相对多了起来。

学习 · 成长

这一年,没有参加一场线下技术分享大会,而2019年参加了5场线下技术分享类大会。
这一年,输入输出少了,只写了3篇文章。
这一年,微信读书记录只看完了4本书。微信读书非常不错,很多书都有,如果喜欢读书的你还没用过微信读书,可以尝试使用。

体验 · 突破

这一年,也没有什么特别的体验突破。

休闲 · 放松

这一年,很长时间都陷入迷茫焦虑中,有个词语叫低欲望人群,说的可能就是我这种。周末经常看电视剧或电影打发时光,麻痹自己...最长的一天(8月16日周日)看了长达7小时。看过《庆余年》、抗疫剧《在一起》、《我在未来等你》、《花木兰》等。


这一年,没有去旅行。刚毕业时2016-2017年,那时周末有空都会在杭州一些景点或大学逛逛。如今几乎不逛了。

总结

站在一年的时间节点上来看全年所度过的光阴。不免又想起孔子在川上的感慨:逝者如斯夫,不舍昼夜。一年很短,列年度计划时满怀信心,写年度总结时却早已忘却当初的计划可能是多数人的状态。以前写过一篇《如何制定有价值的目标》,但真正目标管理很好的人是少数。以往年度总结中写过的一句话「人们往往容易高估自己一年能完成的事,低估自己五年内能完成的事」同样适用。

写年度总结的作用在于每年都自我审视和复盘,多年以后能回顾那一年做成了什么、没做到什么,也许未来的自己会感谢当初努力的自己。通过一定的努力积累,平静的湖面,也许会激起水花。

我的2020年,总结起来真的是很普通。只写了3篇文章,佛系运营了公众号「若川视野」,知乎平台积累了63w+阅读量,多年累计起来,全网也算是超过百万阅读。
于是把公众号简介改为如下:

我是若川,《学习源码整体架构系列》作者,知乎、掘金等平台的文章累计超过百万阅读。致力于前端开发经验分享。愿景:帮助5年内前端人开阔视野不断成长,走在互联网行业前列。

从2018年起,年度总结文章里基本不列举年度计划...这篇文章发给我的群里小伙伴试读时,有人说写写2021年度计划呀。那就简单写下2点:
第一点是:自媒体好好运营,特别是公众号「若川视野」,创作出更多优质的内容,适当招聘小助理分担部分工作,尝试更多可能。
第二点则是:「好好工作,多赚点钱」。


不知不觉写了5000+字,感谢作为读者朋友的你看到这里
最后农历新年即将到来,预祝各位读者朋友过一个快乐中国年。在新的一年,遇见更好的自己

若川
2021年2月4日
查看原文

赞 5 收藏 1 评论 5

蒋鹏飞 发布了文章 · 1月28日

歪门邪道性能优化:魔改三方库源码,性能提高几十倍!

本文会分享一个React性能优化的故事,这也是我在工作中真实遇到的故事,最终我们是通过魔改第三方库源码将它性能提高了几十倍。这个第三方库也是很有名的,在GitHub上有4.5k star,这就是:react-big-calendar

这个工作不是我一个人做的,而是我们团队几个月前共同完成的,我觉得挺有意思,就将它复盘总结了一下,分享给大家

在本文中你可以看到:

  1. React常用性能分析工具的使用介绍
  2. 性能问题的定位思路
  3. 常见性能优化的方式和效果:PureComponent, shouldComponentUpdate, Context, 按需渲染等等
  4. 对于第三方库的问题的解决思路

关于我工作中遇到的故事,我前面其实也分享过两篇文章了:

  1. 速度提高几百倍,记一次数据结构在实际工作中的运用
  2. 使用mono-repo实现跨项目组件共享

特别是速度提高几百倍,记一次数据结构在实际工作中的运用,这篇文章在某平台单篇阅读都有三万多,有些朋友也提出了质疑。觉得我这篇文章里面提到的问题现实中不太可能遇到,里面的性能优化更多是偏理论的,有点杞人忧天。这个观点我基本是认可的,我在那篇文章正文也提到过可能是个伪需求,但是技术问题本来很多就是理论上的,我们在leetcode上刷题还是纯理论呢,理论结合实际才能发挥其真正的价值,即使是杞人忧天,但是性能确实快上了那么一点点,也给大家提供了另一个思路,我觉得也是值得的。

与之相对的,本文提到的问题完全不是杞人忧天了,而是实打实的用户需求,我们经过用户调研,发现用户确实有这么多数据量,需求上不可能再压缩了,只能技术上优化,这也是逼得我们去改第三方库源码的原因。

需求背景

老规矩,为了让大家快速理解我们遇到的问题,我会简单讲一下我们的需求背景。我还是在那家外企,不久前我们接到一个需求:做一个体育场馆管理Web App。这里面有一个核心功能是场馆日程的管理,有点类似于大家Outlook里面的Calendar。大家如果用过Outlook,应该对他的Calendar有印象,基本上我们的会议及其他日程安排都可以很方便的放在里面。我们要做的这个也是类似的,体育场馆的老板可以用这个日历来管理他下面场地的预定。

假设你现在是一个羽毛球场的老板,来了个客户说,嘿,老板,这周六场地有空吗,我订一个小时呢!场馆每天都很多预定,你也不记得周六有没有空,所以你打开我们的网站,看了下日历:

image-20210117111412119

你发现1月15号,也就是星期五有两个预定,周六还全是空闲的,于是给他说:你运气真好,周六目前还没人预定,时段随便挑!上面这个截图是react-big-calendar的官方示例,我们也是选定用他来搭建我们自己的应用。

真实场景

上面这个例子只是说明下我们的应用场景,里面预定只有两个,场地只有一块。但是我们真实的客户可比这个大多了,根据我们的调研,我们较大的客户有数百块场地,每个场地每天的预定可能有二三十个。上面那个例子我们换个生意比较好的老板,假设这个老板有20块羽毛球场地,每天客户都很多,某天还是来了个客户说,嘿,老板,这周六场地有空吗,我订一个小时呢!但是这个老板生意很好,他看到的日历是这样的:

image-20210117112848684

本周场馆1全满!!如果老板想要为客户找到一个有空的场地,他需要连续切换场馆1,场馆2。。。一直到场馆20,手都点酸了。。。为了减少老板手的负担,我们的产品经理提出一个需求,同时在页面上显示10个场馆的日历,好在react-big-calendar本身就是支持这个的,他把这个叫做resources

性能爆炸

看起来我们要的基本功能react-big-calendar都能提供,前途还是很美好的,直到我们将真实的数据渲染到页面上。。。我们的预定不仅仅是展示,还需要支持一系列的操作,比如编辑,复制,剪切,粘贴,拖拽等等。当然这一切操作的前提都是选中这个预定,下面这个截图是我选中某个预定的耗时:

image-20210117114847440

仅仅是一个最简单的点击事件,脚本执行耗时6827ms,渲染耗时708ms,总计耗时7.5s左右,这TM!这玩意儿还想卖钱?送给我,我都不想用

可能有朋友不知道这个性能怎么看,这其实是Chrome自带的性能工具,基本步骤是:

  1. 打开Chrome调试工具,点到Performance一栏
  2. 点击左上角的小圆点,开始录制
  3. 执行你想要的操作,我这里就是点击一个预定
  4. 等你想要的结果出来,我这里就是点击的预定颜色加深
  5. 再点击左上角的小圆点,结束录制就可以看到了

为了让大家看得更清楚,我这里录制了一个操作的动图,这个图可以看到,点击操作的响应花了很长时间,Chrome加载这个性能数据也花了很长时间:

Jan-17-2021 12-51-51

测试数据量

上面仅仅一个点击耗时就七八秒,是因为我故意用了很大数据量吗?不是!我的测试数据量是完全按照用户真实场景计算的:同时显示10个场馆,每个场馆每天20个预定,上面使用的是周视图,也就是可以同时看到7天的数据,那总共显示的预定就是:

10 * 20 * 7 = 1400,总共1400个预定显示在页面上。

为了跟上面这个龟速点击做个对比,我再放下优化后的动图,让大家对后面这个长篇大论实现的效果先有个预期:

Jan-20-2021 16-42-53

定位问题

我们一般印象中,React不至于这么慢啊,如果慢了,大概率是写代码的人没写好!我们都知道React有个虚拟树,当一个状态改变了,我们只需要更新与这个状态相关的节点就行了,出现这种情况,是不是他干了其他不必要的更新与渲染呢?为了解决这个疑惑,我们安装了React专用调试工具:React Developer Tools。这是一个Chrome的插件,Chrome插件市场可以下载,安装成功后,Chrome的调试工具下面会多两个Tab页:

image-20210117130740746

Components这个Tab下有个设置,打开这个设置可以看到你每次操作触发哪些组件更新,我们就是从这里面发现了一点惊喜:

image-20210117130951475

为了看清楚点击事件触发哪些更新,我们先减少数据量,只保留一两个预定,然后打开这个设置看看:

Jan-17-2021 13-21-55

哼,这有点意思。。。我只是点击一个预定,你把整个日历的所有组件都给我更新了!那整个日历有多少组件呢?上面这个图可以看出10:00 AM10:30 AM之间是一个大格子,其实这个大格子中间还有条分割线,只是颜色较淡,看的不明显,也就是说每15分钟就是一个格子。这个15分钟是可以配置的,你也可以设置为1分钟,但是那样格子更多,性能更差!我们是根据需求给用户提供了15分钟,30分钟,1小时等三个选项。当用户选择15分钟的时候,渲染的格子最多,性能最差。

那如果一个格子是15分钟,总共有多少格子呢?一天是24 * 60 = 1440分钟,15分钟一个格子,总共96个格子。我们周视图最多展示7天,那就是7 * 96 = 672格子,最多可以展示10个场馆,就是672 * 10 = 6720个格子,这还没算日期和时间本身占据的组件,四舍五入一下姑且就算7000个格子吧。

我仅仅是点击一下预定,你就把作为背景的7000个格子全部给我更新一遍,怪不得性能差

再仔细看下上面这个动图,我点击的是小的那个事件,当我点击他时,注意大的那个事件也更新了,外面也有个蓝框,不是很明显,但是确实是更新了,在我后面调试打Log的时候也证实了这一点。所以在真实1400条数据下,被更新的还有另外1399个事件,这其实也是不必要的。

我这里提到的事件和前文提到的预定是一个东西,react-big-calendar里面将这个称为event,也就是事件,对应我们业务的意义就是预定

为什么会这样?

这个现象我好像似曾相识,也是我们经常会犯的一个性能上的问题:将一个状态放到最顶层,然后一层一层往下传,当下面某个元素更新了这个状态,会导致根节点更新,从而触发下面所有子节点的更新。这里说的更新并不一定要重新渲染DOM节点,但是会运行每个子节点的render函数,然后根据render函数运行结果来做diff,看看要不要更新这个DOM节点。React在这一步会帮我们省略不必要的DOM操作,但是render函数的运行却是必须的,而成千上万次render函数的运行也会消耗大量性能。

说到这个我想起以前看到过的一个资料,也是讲这个问题的,他用了一个一万行的列表来做例子,原文在这里:high-performance-redux。下面这个例子来源于这篇文章:

function itemsReducer(state = initial_state, action) {
  switch (action.type) {
  case 'MARK':
    return state.map((item) =>
      action.id === item.id ?
        {...item, marked: !item.marked } :
        item
    );
  default:
    return state;
  }
}

class App extends Component {
  render() {
    const { items, markItem } = this.props;
    return (
      <div>
        {items.map(item =>
          <Item key={item.id} id={item.id} marked={item.marked} onClick={markItem} />
        )}
      </div>
    );
  }
};

function mapStateToProps(state) {
  return state;
}

const markItem = (id) => ({type: 'MARK', id});

export default connect(
  mapStateToProps,
  {markItem}
)(App);

上面这段代码不复杂,就是一个App,接收一个items参数,然后将这个参数全部渲染成Item组件,然后你可以点击单个Item来改变他的选中状态,运行效果如下:

Jan-17-2021 15-17-38

这段代码所有数据都在items里面,这个参数从顶层App传进去,当点击Item的时候改变items数据,从而更新整个列表。这个运行结果跟我们上面的Calendar有类似的问题,当单条Item状态改变的时候,其他没有涉及的Item也会更新。原因也是一样的:顶层的参数items改变了。

说实话,类似的写法我见过很多,即使不是从App传入,也会从其他大的组件节点传入,从而引起类似的问题。当数据量少的时候,这个问题不明显,很多时候都被忽略了,像上面这个图,即使一万条数据,因为每个Item都很简单,所以运行一万次render你也不会明显感知出来,在控制台看也就一百多毫秒。但是我们面临的Calendar就复杂多了,每个子节点的运算逻辑都更复杂,最终将我们的响应速度拖累到了七八秒上。

优化方案

还是先说这个一万条的列表,原作者除了提出问题外,也提出了解决方案:顶层App只传id,Item渲染的数据自己连接redux store获取。下面这段代码同样来自这篇文章:

// index.js
function items(state = initial_state, action) {
  switch (action.type) {
  case 'MARK':
    const item = state[action.id];
    return {
      ...state,
      [action.id]: {...item, marked: !item.marked}
    };
  default:
    return state;
  }
}

function ids(state = initial_ids, action) {
  return state;
}

function itemsReducer(state = {}, action) {
  return {
    // 注意这里,数据多了一个ids
    ids: ids(state.ids, action),
    items: items(state.items, action),
  }
}

const store = createStore(itemsReducer);

export default class NaiveList extends Component {
  render() {
    return (
      <Provider store={store}>
        <App />
      </Provider>
    );
  }
}
// app.js
class App extends Component {
  static rerenderViz = true;
  render() {
    // App组件只使用ids来渲染列表,不关心具体的数据
    const { ids } = this.props;
    return (
      <div>
        {
          ids.map(id => {
            return <Item key={id} id={id} />;
          })
        }
      </div>
    );
  }
};

function mapStateToProps(state) {
  return {ids: state.ids};
}

export default connect(mapStateToProps)(App);
// Item.js
// Item组件自己去连接Redux获取数据
class Item extends Component {
  constructor() {
    super();
    this.onClick = this.onClick.bind(this);
  }

  onClick() {
    this.props.markItem(this.props.id);
  }

  render() {
    const {id, marked} = this.props.item;
    const bgColor = marked ? '#ECF0F1' : '#fff';
    return (
      <div
        onClick={this.onClick}
      >
        {id}
      </div>
    );
  }
}

function mapStateToProps(_, initialProps) {
  const { id } = initialProps;
  return (state) => {
    const { items } = state;
    return {
      item: items[id],
    };
  }
}

const markItem = (id) => ({type: 'MARK', id});

export default connect(mapStateToProps, {markItem})(Item);

这段代码的优化主要在这几个地方:

  1. 将数据从单纯的items拆分成了idsitems
  2. 顶层组件App使用ids来渲染列表,ids里面只有id,所以只要不是增加和删除,仅仅单条数据的状态变化,ids并不需要变化,所以App不会更新。
  3. Item组件自己去连接自己需要的数据,当自己关心的数据变化时才更新,其他组件的数据变化并不会触发更新。

拆解第三方库源码

上面通过使用调试工具我看到了一个熟悉的现象,并猜到了他慢的原因,但是目前仅仅是猜测,具体是不是这个原因还要看看他的源码才能确认。好在我在看他的源码前先去看了下他的文档,然后发现了这个:

image-20210117162411789

react-big-calendar接收两个参数onSelectEventselectedselected表示当前被选中的事件(预定),onSelectEvent可以用来改变selected的值。也就是说当我们选中某个预定的时候,会改变selected的值,由于这个参数是从顶层往下传的,所以他会引起下面所有子节点的更新,在我们这里就是差不多7000个背景格子 + 1399个其他事件,这样就导致不需要更新的组件更新了。

顶层selected换成Context?

react-big-calendar在顶层设计selected这样一个参数是可以理解的,因为使用者可以通过修改这个值来控制选中的事件。这样选中一个事件就有了两个途径:

  1. 用户通过点击某个事件来改变selected的值
  2. 开发者可以在外部直接修改selected的值来选中某个事件

有了前面一万条数据列表优化的经验,我们知道对于这种问题的处理办法了:使用selected的组件自己去连接Redux获取值,而不是从顶部传入。可惜,react-big-calendar并没有使用Redux,也没有使用其他任何状态管理库。如果他使用Redux,我们还可以考虑添加一个action来给外部修改selected,可惜他没有。没有Redux就玩不转了吗?当然不是!React其实自带一个全局状态共享的功能,那就是ContextReact Context API官方有详细介绍我之前的一篇文章也介绍过他的基本使用方法,这里不再讲述他的基本用法,我这里想提的是他的另一个特性:使用Context Provider包裹时,如果你传入的value变了,会运行下面所有节点的render函数,这跟前面提到的普通props是一样的。但是,如果Provider下面的儿子节点是PureComponent,可以不运行儿子节点的render函数,而直接运行使用这个value的孙子节点

什么意思呢,下面我将我们面临的问题简化来说明下。假设我们只有三层,第一层是顶层容器Calendar,第二层是背景的空白格子(儿子),第三层是真正需要使用selected的事件(孙子):

image-20210119144005794

示例代码如下:

// SelectContext.js
// 一个简单的Context
import React from 'react'

const SelectContext = React.createContext()

export default SelectContext;
// Calendar.js
// 使用Context Provider包裹,接收参数selected,渲染背景Background
import SelectContext from './SelectContext';

class Calendar extends Component {
  constructor(...args) {
    super(...args)
    
    this.state = {
      selected: null
    };
    
    this.setSelected = this.setSelected.bind(this);
  }
  
  setSelected(selected) {
    this.setState({ selected })
  }
  
  componentDidMount() {
    const { selected } = this.props;
    
    this.setSelected(selected);
  }
  
  render() {
    const { selected } = this.state;
    const value = {
      selected,
      setSelected: this.setSelected
    }
    
    return (
        <SelectContext.Provider value={value}>
          <Background />
      </SelectContext.Provider>
    )
  }
}
// Background.js
// 继承自PureComponent,渲染背景格子和事件Event
class Background extends PureComponent {
  render() {
    const { events } = this.props;
    return  (
        <div>
          <div>这里面是7000个背景格子</div>
          下面是渲染1400个事件
          {events.map(event => <Event event={event}/>)}
      </div>
    )
  }
}
// Event.js
// 从Context中取selected来决定自己的渲染样式
import SelectContext from './SelectContext';

class Event extends Component {
  render() {
    const { selected, setSelected } = this.context;
    const { event } = this.props;
    
    return (
        <div className={ selected === event ? 'class1' : 'class2'} onClick={() => setSelected(event)}>
      </div>
    )
  }
}

Event.contextType = SelectContext;    // 连接Context

什么是PureComponent?

我们知道如果我们想阻止一个组件的render函数运行,我们可以在shouldComponentUpdate返回false,当新的props相对于老的props来说没有变化时,其实就不需要运行rendershouldComponentUpdate就可以这样写:

shouldComponentUpdate(nextProps) {
    const fields = Object.keys(this.props)
    const fieldsLength = fields.length
    let flag = false

    for (let i = 0; i < fieldsLength; i = i + 1) {
      const field = fields[i]
      if (
        this.props[field] !== nextProps[field]
      ) {
        flag = true
        break
      }
    }

    return flag
  }

这段代码就是将新的nextProps与老的props一一进行对比,如果一样就返回false,不需要运行render。而PureComponent其实就是React官方帮我们实现了这样一个shouldComponentUpdate。所以我们上面的Background组件继承自PureComponent,就自带了这么一个优化。如果Background本身的参数没有变化,他就不会更新,而Event因为自己连接了SelectContext,所以当SelectContext的值变化的时候,Event会更新。这就实现了我前面说的如果Provider下面的儿子节点是PureComponent,可以不运行儿子节点的render函数,而直接运行使用这个value的孙子节点

PureComponent不起作用

理想是美好的,现实是骨感的。。。理论上来说,如果我将中间儿子这层改成了PureComponent,背景上7000个格子就不应该更新了,性能应该大幅提高才对。但是我测试后发现并没有什么用,这7000个格子还是更新了,什么鬼?其实这是PureComponent本身的一个问题:只进行浅比较。注意this.props[field] !== nextProps[field],如果this.props[field]是个引用对象呢,比如对象,数组之类的?因为他是浅比较,所以即使前后属性内容没变,但是引用地址变了,这两个就不一样了,就会导致组件的更新!

而在react-big-calendar里面大量存在这种计算后返回新的对象的操作,比如他在顶层Calendar里面有这种操作:

image-20210119151326161

代码地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790

这行代码的意思是每次props改变都去重新计算状态state,而他的计算代码是这样的:

image-20210119151747973

代码地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794

注意他的返回值是一个新的对象,而且这个对象里面的属性,比如localizer的计算方法mergeWithDefaults也是这样,每次都返回新的对象:

image-20210119151956459

代码地址:https://github.com/jquense/react-big-calendar/blob/master/src/localizer.js#L39

这样会导致中间儿子节点每次接受到的props虽然内容是一样的,但是因为是一个新对象,即使使用了PureComponent,其运行结果也是需要更新。这种操作在他的源码中大量存在,其实从功能角度来说,这样写是可以理解的,因为我有时候也会这么干。。。有时候某个属性更新了,不太确定要不要更新下面的组件,干脆直接返回一个新对象触发更新,省事是省事了,但是面对我们这种近万个组件的时候性能就崩了。。。

歪门邪道shouldComponentUpdate

如果只有一两个属性是这样返回新对象,我还可以考虑给他重构下,但是调试了一下发现有大量的属性都是这样,咱也不是他作者,也不知道会不会改坏功能,没敢乱动。但是不动性能也绷不住啊,想来想去,还是在儿子的shouldComponentUpdate上动点手脚吧。简单的this.props[field] !== nextProps[field]判断肯定是不行的,因为引用地址变啦,但是他内容其实是没变,那我们就判断他的内容吧。两个对象的深度比较需要使用递归,也可以参考React diff算法来进行性能优化,但是无论你怎么优化这个算法,性能最差的时候都是两个对象一样的时候,因为他们是一样的,你需要遍历到最深处才能肯定他们是一样的,如果对象很深,这种递归算法不见得会比运行一遍render快,而我们面临的大多数情况都是这种性能最差的情况。所以递归对比不太靠谱,其实如果你对这些数据心里有数,没有循环引用什么的,你可以考虑直接将两个对象转化为字符串来进行对比,也就是

JSON.stringify(this.props[field]) !== JSON.stringify(nextProps[field])

注意,这种方式只适用于你对props数据了解,没有循环引用,没有变化的Symbol,函数之类的属性,因为JSON.stringify执行时会丢掉Symbol和函数,所以我说他是歪门邪道性能优化

将这个转化为字符串比较的shouldComponentUpdate加到背景格子的组件上,性能得到了明显增强,点击相应速度从7.5秒下降到了5.3秒左右。

image-20210119160608456

按需渲染

上面我们用shouldComponentUpdate阻止了7000个背景格子的更新,响应时间下降了两秒多,但是还是需要5秒多时间,这也很难接受,还需要进一步优化。按照我们之前说的如果还能阻止另外1399个事件的更新那就更好了,但是经过对他数据结构的分析,我们发现他的数据结构跟我们前面举的列表例子还不一样。我们列表的例子所有数据都在items里面,是否选中是item的一个属性,而react-big-calendar的数据结构里面eventselectedEvent是两个不同的属性,每个事件通过判断自己的event是否等于selectedEvent来判断自己是否被选中。这造成的结果就是每次我们选中一个事件,selectedEvent的值都会变化,每个事件的属性都会变化,也就是会更新,运行render函数。如果不改这种数据结构,是阻止不了另外1399个事件更新的。但是改这个数据结构改动太大,对于一个第三方库,我们又不想动这么多,怎么办呢?

这条路走不通了,我们完全可以换一个思路,背景7000个格子,再加上1400个事件,用户屏幕有那么大吗,看得完吗?肯定是看不完的,既然看不完,那我们只渲染他能看到部分不就可以了!按照这个思路,我们找到了一个库:react-visibility-sensor。这个库使用方法也很简单:

function MyComponent (props) {
  return (
    <VisibilitySensor>
      {({isVisible}) =>
        <div>I am {isVisible ? 'visible' : 'invisible'}</div>
      }
    </VisibilitySensor>
  );
}

结合我们前面说的,我们可以将VisibilitySensor套在Background上面:

class Background extends PureComponent {
  render() {
    return (
      <VisibilitySensor>
        {({isVisible}) =>
          <Event isVisible={isVisible}/>
        }
      </VisibilitySensor>
    )
  }
}

然后Event组件如果发现自己处于不可见状态,就不用渲染了,只有当自己可见时才渲染:

class Event extends Component {
  render() {
    const { selected } = this.context;
    const { isVisible, event } = this.props;
    
    return (
      { isVisible ? (
       <div className={ selected === event ? 'class1' : 'class2'}>
          复杂内容
       </div>
      ) : null}
    )
  }
}

Event.contextType = SelectContext;

按照这个思路我们又改了一下,发现性能又提升了,整体时间下降到了大概4.1秒:

image-20210120140421092

仔细看上图,我们发现渲染事件Rendering时间从1秒左右下降到了43毫秒,快了二十几倍,这得益于渲染内容的减少,但是Scripting时间,也就是脚本执行时间仍然高达4.1秒,还需要进一步优化。

砍掉mousedown事件

渲染这块已经没有太多办法可以用了,只能看看Scripting了,我们发现性能图上鼠标事件有点刺眼:

image-20210119170345316

一次点击同时触发了三个点击事件:mousedownmouseupclick。如果我们能干掉mousedownmouseup是不是时间又可以省一半,先去看看他注册这两个事件时干什么的吧。可以直接在代码里面全局搜mousedown,最终发现都是在Selection.js,通过对这个类代码的阅读,发现他是个典型的观察者模式,然后再搜new Selection找到使用的地方,发现mousedownmouseup主要是用来实现事件的拖拽功能的,mousedown标记拖拽开始,mouseup标记拖拽结束。如果我把它去掉,拖拽功能就没有了。经过跟产品经理沟通,我们后面是需要拖拽的,所以这个不能删。

事情进行到这里,我也没有更多办法了,但是响应时间还是有4秒,真是让人头大

image-20210120144109109

反正没啥好办法了,我就随便点着玩,突然,我发现mousedown的调用栈好像有点问题:

image-20210120144433528

这个调用栈我用数字分成了三块:

  1. 这里面有很多熟悉的函数名啊,像啥performUnitOfWorkbeginWork,这不都是我在React Fiber这篇文章中提过的吗?所以这些是React自己内部的函数调用
  2. render函数,这是某个组件的渲染函数
  3. 这个render里面又调用了renderEvents函数,看起来是用来渲染事件列表的,主要的时间都耗在这里了

mousedown监听本身我是干不掉了,但是里面的执行是不是可以优化呢?renderEvents已经是库自己写的代码了,所以可以直接全局搜,看看在哪里执行的。最终发现是在TimeGrid.jsrender函数被执行了,其实这个是不需要执行的,我们直接把前面歪门邪道的shouldComponentUpdate复制过来就可以阻止他的执行。然后再看下性能数据呢:

image-20210120145945555

我们发现Scripting下降到了3.2秒左右,比之前减少约800毫秒,而mousedown的时间也从之前的几百毫秒下降到了50毫秒,在图上几乎都看不到了,mouseup事件也不怎么看得到了,又算进了一步吧~

忍痛阉割功能

到目前为止,我们的性能优化都没有阉割功能,响应速度从7.5秒下降到了3秒多一点,优化差不多一倍。但是,目前这速度还是要三秒多,别说作为一个工程师了,作为一个用户我都忍不了。咋办呢?我们是真的有点黔驴技穷了。。。

看看上面那个性能图,主要消耗时间的有两个,一个是click事件,还有个timertimer到现在我还不知道他哪里来的,但是click事件我们是知道的,就是用户点击某个事件后,更改SelectContextselected属性,然后selected属性从顶层节点传入触发下面组件的更新,中间儿子节点通过shouldComponentUpdate跳过更新,孙子节点直接连接SelectContext获取selected属性更新自己的状态。这个流程是我们前面优化过的,但是,等等,这个貌似还有点问题。

在我们的场景中,中间儿子节点其实包含了高达7000个背景格子,虽然我们通过shouldComponentUpdate跳过了render的执行,但是7000个shouldComponentUpdate本省执行也是需要时间的啊!有没有办法连shouldComponentUpdate的执行也跳过呢?这貌似是个新的思路,但是经过我们的讨论,发现没办法在保持功能的情况下做到,但是可以适度阉割一个功能就可以做到,那阉割的功能是哪个呢?那就是暴露给外部的受控selected属性!

前面我们提到过选中一个事件有两个途径:

  1. 用户通过点击某个事件来改变selected的值
  2. 开发者可以在外部直接修改selected的值来选中某个事件

之所以selected要放在顶层组件上就是为了实现第二个功能,让外部开发者可以通过这个受控的selected属性来改变选中的事件。但是经过我们评估,外部修改selected这个并不是我们的需求,我们的需求都是用户点击来选中,也就是说外部修改selected这个功能我们可以不要。

如果不要这个功能那就有得玩了,selected完全不用放在顶层了,只需要放在事件外层的容器上就行,这样,改变selected值只会触发事件的更新,啥背景格子的更新压根就不会触发,那怎么改呢?在我们前面的Calendar -- Background -- Event模型上再加一层EventContainer,变成Calendar -- Background -- EventContainer -- EventSelectContext.Provider也不用包裹Calendar了,直接包裹EventContainer就行。代码大概是这个样子:

// Calendar.js
// Calendar简单了,不用接受selected参数,也不用SelectContext.Provider包裹了
class Calendar extends Component {
  render() {
    return (
      <Background />
    )
  }
}
// Background.js
// Background要不要使用shouldComponentUpdate阻止更新可以看看还有没有其他参数变化,因为selected已经从顶层拿掉了
// 改变selected本来就不会触发Background更新
// Background不再渲染单个事件,而是渲染EventContainer
class Background extends PureComponent {
  render() {
    const { events } = this.props;
    return  (
        <div>
          <div>这里面是7000个背景格子</div>
          下面是渲染1400个事件
          <EventContainer events={events}/>
      </div>
    )
  }
}
// EventContainer.js
// EventContainer需要SelectContext.Provider包裹
// 代码类似之前的Calendar
import SelectContext from './SelectContext';

class EventContainer extends Component {
  constructor(...args) {
    super(...args)
    
    this.state = {
      selected: null
    };
    
    this.setSelected = this.setSelected.bind(this);
  }
  
  setSelected(selected) {
    this.setState({ selected })
  }
  
  render() {
    const { selected } = this.state;
    const { events } = this.props;
    const value = {
      selected,
      setSelected: this.setSelected
    }
    
    return (
        <SelectContext.Provider value={value}>
          {events.map(event => <Event event={event}/>)}
      </SelectContext.Provider>
    )
  }
}
// Event.js
// Event跟之前是一样的,从Context中取selected来决定自己的渲染样式
import SelectContext from './SelectContext';

class Event extends Component {
  render() {
    const { selected, setSelected } = this.context;
    const { event } = this.props;
    
    return (
        <div className={ selected === event ? 'class1' : 'class2'} onClick={() => setSelected(event)}>
      </div>
    )
  }
}

Event.contextType = SelectContext;    // 连接Context

这种结构最大的变化就是当selected变化的时候,更新的节点是EventContainer,而不是顶层Calendar,这样就不会触发Calendar下其他节点的更新。缺点就是Calendar无法从外部接收selected了。

需要注意一点是,如果像我们这样EventContainer下面直接渲染Event列表,selected不用Context也可以,可以直接作为EventContainerstate但是如果EventContainerEvent中间还有层级,需要穿透传递,仍然需要Context,中间层级和以前的类似,使用shouldComponentUpdate阻止更新

还有一点,因为selected不在顶层了,所以selected更新也不会触发中间Background更新了,所以Background上的shouldComponentUpdate也可以删掉了。

我们这样优化后,性能又提升了:

image-20210120161336248

现在Scripting时间直接从3.2秒降到了800毫秒,其中click事件只有163毫秒,现在从我使用来看,卡顿已经不明显了,直接录个动图来对比下吧:

Jan-20-2021 16-42-53

上面这个动图已经基本看不出卡顿了,但是我们性能图上为啥还有800毫秒呢,而且有一个很长的Timer Fired。经过我们的仔细排查,发现这其实是个乌龙,Timer Fired在我一开始录制性能就出现了,那时候我还在切换页面,还没来得及点击呢,如果我们点进去会发现他其实是按需渲染引入的react-visibility-sensor的一个检查元素可见性的定时任务,并不是我们点击事件的响应时间。把这块去掉,我们点击事件的响应时间其实不到200毫秒。

从7秒多优化到不到200毫秒,三十多倍的性能优化,终于可以交差了,哈哈😃

总结

本文分享的是我工作中实际遇到的一个案例,实现的效果是将7秒左右的响应时间优化到了不到200毫秒,优化了三十几倍,优化的代价是牺牲了一个不常用的功能。

本来想着要是优化好了可以给这个库提个PR,造福大家的。但是优化方案确实有点歪门邪道:

  1. 使用了JSON.stringify来进行shouldComponentUpdate的对比优化,对于函数,Symbol属性的改变没法监听到,不适合开放使用,只能在数据自己可控的情况下小规模使用。
  2. 牺牲了一个暴露给外部的受控属性selected,破坏了功能。

基于这两点,PR我们就没提了,而是将修改后的代码放到了自己的私有NPM仓库。

下面再来总结下本文面临的问题和优化思路:

遇到的问题

我们需求是要做一个体育场馆的管理日历,所以我们使用了react-big-calendar这个库。我们需求的数据量是渲染7000个背景格子,然后在这个背景格子上渲染1400个事件。这近万个组件渲染后,我们发现仅仅一次点击就需要7秒多,完全不能用。经过细致排查,我们发现慢的原因是点击事件的时候会改变一个属性selected。这个属性是从顶层传下来的,改变后会导致所有组件更新,也就是所有组件都会运行render函数。

第一步优化

为了阻止不必要的render运行,我们引入了Context,将selected放到Context上进行透传。中间层级因为不需要使用selected属性,所以可以使用shouldComponentUpdate来阻止render的运行,底层需要使用selected的组件自行连接Context获取。

第一步优化的效果

响应时间从7秒多下降到5秒多。

第一步优化的问题

底层事件仍然有1400个,获取selected属性后,1400个组件更新仍然要花大量的时间。

第二步优化

为了减少点击后更新的事件数量,我们为事件引入按需渲染,只渲染用户可见的事件组件。同时我们还对mousedownmouseup进行了优化,也是使用shouldComponentUpdate阻止了不必要的更新。

第二步优化效果

响应时间从5秒多下降到3秒多。

第二步优化的问题

响应时间仍然有三秒多,经过分析发现,背景7000个格子虽然使用shouldComponentUpdate阻止了render函数的运行,但是shouldComponentUpdate本身运行7000次也要费很长时间。

第三步优化

为了让7000背景格子连shouldComponentUpdate都不运行,我们忍痛阉割了顶层受控的selected属性,直接将它放到了事件的容器上,它的更新再也不会触发背景格子的更新了,也就是连shouldComponentUpdate都不运行了。

第三步优化效果

响应时间从3秒多下降到不到200毫秒。

第三步优化的问题

功能被阉割了,其他完美!

参考资料:

react-big-calendar仓库

high-performance-redux

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 34 收藏 19 评论 12

蒋鹏飞 赞了文章 · 1月22日

这次,十分钟把宏任务和微任务讲清楚

为什么写这个文章

  • 这是一道大厂、小厂面试官都喜欢问的题目
  • 很多面试官和面试者也不知道什么是标准答案
  • 网上各种文章层次不齐..误导过不少人,包括我
  • 觉得还是今天花十分钟讲清楚他吧

正式开始

  • 先上代码
    function app() {
      setTimeout(() => {
        console.log("1-1");
        Promise.resolve().then(() => {
          console.log("2-1");
        });
      });
      console.log("1-2");
      Promise.resolve().then(() => {
        console.log("1-3");
        setTimeout(() => {
          console.log("3-1");
        });
      });
    }
    app();
  • 输出结果:
1-2
1-3
1-1
2-1
3-1

开始分析

  • 面试官特别喜欢问:你讲讲什么是微任务和宏任务
大部分面试官其实自己也不懂什么是微任务和宏任务,不信下次你们反问一下

所谓微任务和宏任务

  • 宏任务:常见的定时器,用户交互事件等等.(宏任务就是特定的这些个任务,没什么特殊含义)
  • 微任务:Promise相关任务,MutationObserver等(一样,只是一种称呼而已!!!

到底先执行微任务还是宏任务

  • 先有鸡还是先有蛋? 到底是先有宏任务还是微任务啊?

第一个原则

  • 万物皆从全局上下文准备退出,全局的同步代码运行结束的这个时机开始
  • 例如我们刚才这段代码:
   function app() {
      setTimeout(() => {
        console.log("1-1");
        Promise.resolve().then(() => {
          console.log("2-1");
        });
      });
      console.log("1-2");
      Promise.resolve().then(() => {
        console.log("1-3");
        setTimeout(() => {
          console.log("3-1");
        });
      });
    }
    app();
  • 当执行完了console.log("1-2");的时候,意味着全局的上下文马上要退出了,因为此时全局的同步代码都执行完了,剩下的都是异步代码

第二个原则

  • 同一层级下(不理解层级,可以先不管,后面会讲),微任务永远比宏任务先执行
  • 即Promise.then比setTimeout先执行
  • 所以先打印1-3,再打印1-1

第三个原则

  • 每个宏任务,都单独关联了一个微任务队列
  • 我用刚买的黑板画了一张图,大家就知道什么是层级了

  • 每个层级的宏任务,都对应了他们的微任务队列,微任务队列遵循先进先出的原则,当全局同步代码执行完毕后,就开始执行第一层的任务。同层级的微任务永远先于宏任务执行,并且会在当前层级宏任务结束前全部执行完毕

怎么分辨层级?

  • 属于同一个维度的代码,例如下面的func1和func2就属于同层级任务
setTimeout(func1)...
Promise.resolve().then(func2)...
  • 下面这种fn1和fn2就不属于同一个层级的,因为fn2属于内部这个setTimeout的微任务队列,而fn1属于外部setTimeout的微任务队列
setTimeout(()=>{
Promise.resolve().then(fn1)
setTimeout(()=>{
Promise.resolve().then(fn2)  
})})
划重点:每个宏任务对应一个单独的微任务队列

遇到面试题

  • 就按照我的套路,从全局上下文退出前(全局的同步代码执行完毕后),开始收集当前层级的微任务和宏任务,然后先清空微任务队列,再执行宏任务.如果这期间遇到宏任务/微任务,就像我这样画个图,把他们塞进对应的层级里即可

写在最后

  • 简单的1000字,相信能彻底解决你的微任务和宏任务疑惑
  • 如果你想理解得更深,记得关注下公众号,后续会写一些更深入的东西,真正的“深入浅出”
查看原文

赞 24 收藏 13 评论 3

蒋鹏飞 发布了文章 · 1月19日

前端也能学算法:由浅入深讲解贪心算法

贪心算法是一种很常见的算法思想,而且很好理解,因为它符合人们一般的思维习惯。下面我们由浅入深的来讲讲贪心算法。

找零问题

我们先来看一个比较简单的问题:

假设你是一个商店老板,你需要给顾客找零n元钱,你手上有的钱的面值为:100元,50元,20元,5元,1元。请问如何找零使得所需要的钱币数量最少?

例子:你需要找零126元,则所需钱币数量最少的方案为100元1找,20元1张,5元1张,1元1张。

这个问题在生活中很常见,买东西的时候经常会遇到,那我们一般是怎么思考的呢?假设我们需要找零126元,我们先看看能找的最大面值是多少,我们发现126比100大,那肯定可以找一张100块,然后剩下26元,再看26能匹配的最大面值是多少,发现是20,那找一张20的,还剩6块,同样的思路,找一张5块的和1块的。这其实就是贪心算法的思想,每次都很贪心的去找最大的匹配那个值,然后再找次大的。这个算法代码也很好写:

const allMoney = [100, 50, 20, 5, 1];  // 表示我们手上有的面值
function changeMoney(n, allMoney) {
  const length = allMoney.length;
  const result = [];    // 存储结果的数组,每项表示对应面值的张数
  for(let i = 0; i < length; i++) {
    if(n >= allMoney[i]) {
      // 如果需要找的钱比面值大,那就可以找,除一下看看能找几张
      result[i] = parseInt(n / allMoney[i]);
      n = n - result[i] * allMoney[i];   // 更新剩下需要找的钱
    } else {
      // 否则不能找
      result[i] = 0;
    }
  }
  
  return result;
}

const result = changeMoney(126, allMoney);
console.log(result);   // [1, 0, 1, 1, 1]

贪心算法

上面的找零问题就是贪心算法,每次都去贪最大面值的,发现贪不了了,再去贪次大的。从概念上讲,贪心算法是:

image-20200220105715893

从上面的定义可以看出,并不是所有问题都可以用贪心算法来求解的,因为它每次拿到的只是局部最优解,局部最优解组合起来并不一定是全局最优解。下面我们来看一个这样的例子:

背包问题

背包问题也是一个很经典的算法问题,题目如下:

有一个小偷,他进到了一个店里要偷东西,店里有很多东西,每个东西的价值是v,每个东西的重量是w。但是小偷只有一个背包,他背包总共能承受的重量是W。请问怎么拿东西能让他拿到的价值最大?

其实背包问题细分下来又可以分成两个问题:0-1背包和分数背包。

0-1背包:指的是对于某个商品来说,你要么不拿,要么全拿走,不能只拿一半或者只拿三分之二。可以将商品理解成金砖,你要么整块拿走,要么不拿,不能拿半块。

分数背包:分数背包就是跟0-1背包相反的,你可以只拿一部分,可以拿一半,也可以拿三分之二。可以将商品理解成金砂,可以只拿一部分。

下面来看个例子:

image-20200220110835213

这个问题用我们平时的思维也很好想,要拿到总价值最大,那我们就贪呗,就拿最贵的,即价值除以重量的数最大的。但是每次都拿最贵的,是不是最后总价值最大呢?我们先假设上面的例子是0-1背包,最贵的是v1,然后是v2,v3。我们先拿v1, 背包还剩40,拿到总价值是60,然后拿v2,背包还剩20,拿到总价值是160。然后就拿不下了,因为v3的重量是30,我们背包只剩20了,装不下了。但是这个显然不是全局最优解,因为我们明显可以看出,如果我们拿v2,v3,背包刚好装满,总价值是220,这才是最优解。所以0-1背包问题不能用贪心算法。

但是分数背包可以用贪心,因为我们总是可以拿最贵的。我们先拿了v1, v2,发现v3装不下了,那就不装完了嘛,装三分之二就行了。下面我们用贪心来实现一个分数背包:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
  {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack(W, products) {
  const sortedProducts = products.sort((product1, product2) => {
    const price1 = product1.v / product1.w;
    const price2 = product2.v / product2.w;
    if(price1 > price2) {
      return -1;
    } else if(price1 < price2) {
      return 1;
    }
    
    return 0;
  });  // 先对商品按照价值从大到小排序
  
  const result = []; // 新建数组接收结果
  let allValue = 0;  // 拿到的总价值
  const length = sortedProducts.length;
  
  for(let i = 0; i < length; i++) {
    const sortedProduct = sortedProducts[i];
    if(W >= sortedProduct.w) {
      // 整个拿完
      result.push({
        id: sortedProduct.id,
        take: 1,     // 拿的数量
      });
      W = W - sortedProduct.w;
      allValue = allValue + sortedProduct.v;
    } else if(W > 0) {
      // 只能拿一部分
      result.push({
        id: sortedProduct.id,
        take: W / sortedProduct.w,     
      });
      allValue = allValue + sortedProduct.v * (W / sortedProduct.w);
      W = 0; // 装满了
    } else {
      // 不能拿了
      result.push({
        id: sortedProduct.id,
        take: 0,     
      });
    }
  }
  
  return {result: result, allValue: allValue};
}

// 测试一下
const result = backpack(50, products);
console.log(result);

运行结果:

image-20200220113537290

0-1背包

前面讲过0-1背包不能用贪心求解,我们这里还是讲讲他怎么来求解吧。要解这个问题需要用到动态规划的思想,关于动态规划的思想,可以看看我这篇文章,如果你只想看看贪心算法,可以跳过这一部分。假设我们背包放了n个商品,W是我们背包的总容量,我们这时拥有的总价值是$D(n, W)$。我们考虑最后一步,

假如我们不放最后一个商品,则总价值为$D(n-1, W)$

假设我们放了最后一个商品,则总价值为最后一个商品加上前面已经放了的价值,表示为$v_n + D(n-1, W-w_n)$,这时候需要满足的条件是$ W >= w_n$,即最后一个要放得下。

我们要求的最大解其实就是上述两个方案的最大值,表示如下:

$$ D(n, W) = max(D(n-1, W), v_n + D(n-1, W-w_n)) $$

递归解法

有了递推公式,我们就可以用递归解法了:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack01(n, W, products) {
  if(n < 0 || W <= 0) {
    return 0;
  }
  
  const noLast = backpack01(n-1, W, products);  // 不放最后一个
  
  let getLast = 0;
  if(W >= products[n].w){  // 如果最后一个放得下
    getLast = products[n].v + backpack01(n-1, W-products[n].w, products);
  }
  
  const result = Math.max(noLast, getLast);
  
  return result;
}

// 测试一下
const result = backpack01(products.length-1, 50, products);
console.log(result);   // 220

动态规划

递归的复杂度很高,我们用动态规划重写一下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack01(W, products) {
  const d = [];      // 初始化一个数组放计算中间值,其实为二维数组,后面填充里面的数组
  const length = products.length;
  
  // i表示行,为商品个数,数字为 0 -- (length - 1)
  // j表示列,为背包容量,数字为 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 背包容量为0
        d[i][j] = 0;
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 可以放下第一个商品
          d[i][j] = products[i].v;
        } else {
          d[i][j] = 0;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
        }
      }
    }
  }
  
  console.log(d);
  return d[length-1][W];
}

// 测试一下
const result = backpack01(50, products);
console.log(result);   // 220

回溯最优解

为了能够输出最优解,我们需要将每个最后放入的商品记录下来,然后从最后往前回溯,将前面的代码改造如下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一个数组表示商品列表,每个商品加个id用于标识

function backpack01(W, products) {
  const d = [];      // 初始化一个数组放计算中间值,其实为二维数组,后面填充里面的数组
  const res = [];    // 记录每次放入的最后一个商品, 同样为二维数组
  const length = products.length;
  
  // i表示行,为商品个数,数字为 0 -- (length - 1)
  // j表示列,为背包容量,数字为 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    res.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 背包容量为0
        d[i][j] = 0;
        res[i][j] = null;  
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 可以放下第一个商品
          d[i][j] = products[i].v;
          res[i][j] = products[i];
        } else {
          d[i][j] = 0;
          res[i][j] = null;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
          res[i][j] = products[i];   // 记录最后一个商品
        }
      }
    }
  }
  
  // 回溯res, 得到最优解
  let tempW = W;
  let tempI = length - 1;
  const bestSol = [];
  while (tempW > 0 && tempI >= 0) {
    const last = res[tempI][tempW];
    bestSol.push(last);
    tempW = tempW - last.w;
    tempI = tempI - 1;
  }
  
  console.log(d);
  console.log(bestSol);
  return {
    totalValue: d[length-1][W],
    solution: bestSol
  }
}

// 测试一下
const result = backpack01(50, products);
console.log(result);   // 220

上面代码的输出:

image-20200220144941561

数字拼接问题

再来看一个贪心算法的问题,加深下理解,这个问题如下:

image-20200220153438242

这个问题看起来也不难,我们有时候也会遇到类似的问题,我们可以很直观的想到一个解法:看哪个数字的第一个数字大,把他排前面,比如32和94,把第一位是9的94放前面,得到9432,肯定比32放前面的3294大。这其实就是按照字符串大小来排序嘛,字符大的排前面,但是这种解法正确吗?我们再来看两个数字,假如我们有728和7286,按照字符序,7286排前面,得到7286728,但是这个值没有728放前面的7287286大。说明单纯的字符序是搞不定这个的,对于两个数字a,b,如果他们的长度一样,那按照字符序就没问题,如果他们长度不一样,这个解法就不一定对了,那怎么办呢?其实也简单,我们看看a+b和b+a拼成的数字,哪个大就行了。

假设
a = 728
b = 7286
字符串: a + b = "7287286"
字符串: b + a = "7286728"
比较下这两个字符串, a + b比较大,a放前面就行了, 反之放到后面

上述算法就是一个贪心,这里贪的是什么的?贪的是a + b的值,要大的那个。在实现的时候,可以自己写个冒泡,也可以直接用数组的sort方法:

const nums = [32, 94, 128, 1286, 6, 71];

function getBigNum(nums) {
  nums.sort((a, b) => {
    const ab = `${a}${b}`;
    const ba = `${b}${a}`;
    
    if(ab > ba) {
      return -1;   // ab大,a放前面
    } else if (ab < ba) {
      return 1;  
    }
    
    return 0;
  });
  
  return nums;
}

const res = getBigNum(nums);
console.log(res);    // [94, 71, 6, 32, 1286, 128]

活动选择问题

活动选择问题稍微难一点,也可以用贪心,但是需要贪的东西没前面的题目那么直观,我们先来看看题目:

image-20200220155950342

这个问题应该这么思考:为了能尽量多的安排活动,我们在安排一个活动时,应该尽量给后面的活动多留时间,这样后面有机会可以安排更多的活动。换句话说就是,应该把结束时间最早的活动安排在第一个,再剩下的时间里面继续安排结束时间早的活动。这里的贪心其实贪的就是结束时间早的,这个结论其实可以用数学来证明的:

image-20200220161538654

下面来实现下代码:

const activities = [
  {start: 1, end: 4},
  {start: 3, end: 5},
  {start: 0, end: 6},
  {start: 5, end: 7},
  {start: 3, end: 9},
  {start: 5, end: 9},
  {start: 6, end: 10},
  {start: 8, end: 11},
  {start: 8, end: 12},
  {start: 2, end: 14},
  {start: 12, end: 16},
];

function chooseActivity(activities) {
  // 先按照结束时间从小到大排序
  activities.sort((act1, act2) => {
    if(act1.end < act2.end) {
      return -1;
    } else if(act1.end > act2.end) {
      return 1;
    }
    
    return 0;
  });
  
  const res = [];  // 接收结果的数组
  let lastEnd = 0; // 记录最后一个活动的结束时间
  
  for(let i = 0; i < activities.length; i++){
    const act = activities[i];
    if(act.start >= lastEnd) {
      res.push(act);
      lastEnd = act.end
    }
  }
  
  return res;
}

// 测试一下
const result = chooseActivity(activities);
console.log(result);

上面代码的运行结果如下:

image-20200220163750591

总结

贪心算法的重点就在一个贪字,要找到贪的对象,然后不断的贪,最后把目标贪完,输出最优解。要注意的是,每次贪的时候其实拿到的都只是局部最优解,局部最优解不一定组成全局最优解,比如0-1背包,对于这种问题是不能用贪心的,要用其他方法求解。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 27 收藏 18 评论 6

蒋鹏飞 收藏了文章 · 1月18日

可视化拖拽组件库一些技术要点原理分析(二)

本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:

  1. 编辑器
  2. 自定义组件
  3. 拖拽
  4. 删除组件、调整图层层级
  5. 放大缩小
  6. 撤消、重做
  7. 组件属性设置
  8. 吸附
  9. 预览、保存代码
  10. 绑定事件
  11. 绑定动画
  12. 导入 PSD
  13. 手机模式

现在这篇文章会在此基础上再补充 4 个功能点,分别是:

  • 拖拽旋转
  • 复制粘贴剪切
  • 数据交互
  • 发布

和上篇文章一样,我已经将新功能的代码更新到了 github:

友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:

  1. 不支持拖拽旋转。
  2. 旋转后的放大缩小不正确。
  3. 旋转后的自动吸附不正确。
  4. 旋转后八个可伸缩点的光标不正确。

这一小节,我们将逐一解决这四个问题。

拖拽旋转

拖拽旋转需要使用 Math.atan2() 函数。

Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。

简单的说就是以组件中心点为原点 (centerX,centerY),用户按下鼠标时的坐标设为 (startX,startY),鼠标移动时的坐标设为 (curX,curY)。旋转角度可以通过 (startX,startY)(curX,curY) 计算得出。

那我们如何得到从点 (startX,startY) 到点 (curX,curY) 之间的旋转角度呢?

第一步,鼠标点击时的坐标设为 (startX,startY)

const startY = e.clientY
const startX = e.clientX

第二步,算出组件中心点:

// 获取组件中心点位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

第三步,按住鼠标移动时的坐标设为 (curX,curY)

const curX = moveEvent.clientX
const curY = moveEvent.clientY

第四步,分别算出 (startX,startY)(curX,curY) 对应的角度,再将它们相减得出旋转的角度。另外,还需要注意的就是 Math.atan2() 方法的返回值是一个弧度,因此还需要将弧度转化为角度。所以完整的代码为:

// 旋转前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋转后的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 获取旋转的角度值, startRotate 为初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

放大缩小

组件旋转后的放大缩小会有 BUG。

从上图可以看到,放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是:当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度,放大缩小仍然是按没旋转时计算的。

下面再看一个具体的示例:

从上图可以看出,在没有旋转时,按住顶点往上拖动,只需用 y2 - y1 就可以得出拖动距离 s。这时将组件原来的高度加上 s 就能得出新的高度,同时将组件的 topleft 属性更新。

现在旋转 180 度,如果这时拖住顶点往下拖动,我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的,所以结果和我们期待的相反,组件的高度将会变小(如果不理解这个现象,可以想像一下没有旋转的那张图,按住顶点往下拖动)。

如何解决这个问题呢?我从 github 上的一个项目 snapping-demo 找到了解决方案:将放大缩小和旋转角度关联起来。

解决方案

下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。

现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。

第一步,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 topleft 属性不变)和大小算出组件中心点:

const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}

第二步,用当前点击坐标和组件中心点算出当前点击坐标的对称点坐标:

// 获取画布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()

// 当前点击坐标
const curPoint = {
    x: e.clientX - editorRectInfo.left,
    y: e.clientY - editorRectInfo.top,
}

// 获取对称点的坐标
const symmetricPoint = {
    x: center.x - (curPoint.x - center.x),
    y: center.y - (curPoint.y - center.y),
}

第三步,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:

const curPositon = {
    x: moveEvent.clientX - editorRectInfo.left,
    y: moveEvent.clientY - editorRectInfo.top,
}

const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)

// 求两点之间的中点坐标
function getCenterPoint(p1, p2) {
    return {
        x: p1.x + ((p2.x - p1.x) / 2),
        y: p1.y + ((p2.y - p1.y) / 2),
    }
}

由于组件处于旋转状态,即使你知道了拉伸时移动的 xy 距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。

第四步,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出当前鼠标实时坐标currentPosition 在未旋转时的坐标 newTopLeftPoint。同时也能根据已知的旋转角度、新的组件中心点、对称点算出组件对称点sPoint 在未旋转时的坐标 newBottomRightPoint

对应的计算公式如下:

/**
 * 计算根据圆心旋转后的点的坐标
 * @param   {Object}  point  旋转前的点坐标
 * @param   {Object}  center 旋转中心
 * @param   {Number}  rotate 旋转的角度
 * @return  {Object}         旋转后的坐标
 * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋转公式:
     *  点a(x, y)
     *  旋转中心c(x, y)
     *  旋转后点n(x, y)
     *  旋转角度θ                tan ??
     * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
     * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
     */

    return {
        x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
        y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
    }
}

上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个回答中找到了这一公式的推理过程,下面是回答的原文:

通过以上几个计算值,就可以得到组件新的位移值 topleft 以及新的组件大小。对应的完整代码如下:

function calculateLeftTop(style, curPositon, pointInfo) {
    const { symmetricPoint } = pointInfo
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
  
    const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
    const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (newWidth > 0 && newHeight > 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}

现在再来看一下旋转后的放大缩小:

自动吸附

自动吸附是根据组件的四个属性 topleftwidthheight 计算的,在将组件进行旋转后,这些属性的值是不会变的。所以无论组件旋转多少度,吸附时仍然按未旋转时计算。这样就会有一个问题,虽然实际上组件的 topleftwidthheight 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件:一个没旋转,一个旋转了 45 度。

可以看出来旋转后按钮的 height 属性和我们从外观上看到的高度是不一样的,所以在这种情况下就出现了吸附不正确的 BUG。

解决方案

如何解决这个问题?我们需要拿组件旋转后的大小及位移来做吸附对比。也就是说不要拿组件实际的属性来对比,而是拿我们看到的大小和位移做对比。

从上图可以看出,旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度可以通过正弦和余弦算出,左边的红线用正弦计算,右边的红线用余弦计算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一样:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的宽度和高度有了,再根据组件原有的 topleft 属性,可以得出组件旋转后新的 topleft 属性。下面附上完整代码:

translateComponentStyle(style) {
    style = { ...style }
    if (style.rotate != 0) {
        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

经过修复后,吸附也可以正常显示了。

光标

光标和可拖动的方向不对,是因为八个点的光标是固定设置的,没有随着角度变化而变化。

解决方案

由于 360 / 8 = 45,所以可以为每一个方向分配 45 度的范围,每个范围对应一个光标。同时为每个方向设置一个初始角度,也就是未旋转时组件每个方向对应的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八个方向
initialAngle: { // 每个点对应的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每个范围的角度对应的光标
    { start: 338, end: 23, cursor: 'nw' },
    { start: 23, end: 68, cursor: 'n' },
    { start: 68, end: 113, cursor: 'ne' },
    { start: 113, end: 158, cursor: 'e' },
    { start: 158, end: 203, cursor: 'se' },
    { start: 203, end: 248, cursor: 's' },
    { start: 248, end: 293, cursor: 'sw' },
    { start: 293, end: 338, cursor: 'w' },
],
cursors: {},

计算方式也很简单:

  1. 假设现在组件已旋转了一定的角度 a。
  2. 遍历八个方向,用每个方向的初始角度 + a 得出现在的角度 b。
  3. 遍历 angleToCursor 数组,看看 b 在哪一个范围中,然后将对应的光标返回。

经常上面三个步骤就可以计算出组件旋转后正确的光标方向。具体的代码如下:

getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有负数,所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,降低时间复杂度
    pointList.forEach(point => {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start <= angle && angle < angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},

从上面的动图可以看出来,现在八个方向上的光标是可以正确显示的。

15. 复制粘贴剪切

相对于拖拽旋转功能,复制粘贴就比较简单了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown && e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}

监听用户的按键操作,在按下特定按键时触发对应的操作。

复制操作

在 vuex 中使用 copyData 来表示复制的数据。当用户按下 ctrl + c 时,将当前组件数据深拷贝到 copyData

copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同时需要将当前组件在组件数据中的索引记录起来,在剪切中要用到。

粘贴操作

paste(state, isMouse) {
    if (!state.copyData) {
        toast('请选择组件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data })
    store.commit('recordSnapshot')
    state.copyData = null
},

粘贴时,如果是按键操作 ctrl+v。则将组件的 topleft 属性加 10,以免和原来的组件重叠在一起。如果是使用鼠标右键执行粘贴操作,则将复制的组件放到鼠标点击处。

剪切操作

cut(state) {
    if (!state.curComponent) {
        toast('请选择组件')
        return
    }

    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
            state.curComponentIndex++
        }
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作本质上还是复制,只不过在执行复制后,需要将当前组件删除。为了避免用户执行剪切操作后,不执行粘贴操作,而是继续执行剪切。这时就需要将原先剪切的数据进行恢复。所以复制数据中记录的索引就起作用了,可以通过索引将原来的数据恢复到原来的位置中。

右键操作

右键操作和按键操作是一样的,一个功能两种触发途径。

<li @click="copy" v-show="curComponent">复制</li>
<li @click="paste">粘贴</li>
<li @click="cut" v-show="curComponent">剪切</li>

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},

16. 数据交互

方式一

提前写好一系列 ajax 请求API,点击组件时按需选择 API,选好 API 再填参数。例如下面这个组件,就展示了如何使用 ajax 请求向后台交互:

<template>
    <div>{{ propValue.data }}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res => {
            this.propValue.data = res.data
        })
    },
}
</script>

方式二

方式二适合纯展示的组件,例如有一个报警组件,可以根据后台传来的数据显示对应的颜色。在编辑页面的时候,可以通过 ajax 向后台请求页面能够使用的 websocket 数据:

const data = ['status', 'text'...]

然后再为不同的组件添加上不同的属性。例如有 a 组件,它绑定的属性为 status

// 组件能接收的数据
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},

在组件中通过 wsKey 获取这个绑定的属性。等页面发布后或者预览时,通过 weboscket 向后台请求全局数据放在 vuex 上。组件就可以通过 wsKey 访问数据了。

<template>
    <div>{{ wsData[wsKey] }}</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
</script>

和后台交互的方式有很多种,不仅仅包括上面两种,我在这里仅提供一些思路,以供参考。

17. 发布

页面发布有两种方式:一是将组件数据渲染为一个单独的 HTML 页面;二是从本项目中抽取出一个最小运行时 runtime 作为一个单独的项目。

这里说一下第二种方式,本项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取出来作为一个项目单独打包。发布页面时将组件数据以 JSON 的格式传给服务端,同时为每个页面生成一个唯一 ID。

假设现在有三个页面,发布页面生成的 ID 为 a、b、c。访问页面时只需要把 ID 带上,这样就可以根据 ID 获取每个页面对应的组件数据。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加载

如果自定义组件过大,例如有数十个甚至上百个。这时可以将自定义组件用 import 的方式导入,做到按需加载,减少首屏渲染时间:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {
    Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本发布

自定义组件有可能会有更新的情况。例如原来的组件使用了大半年,现在有功能变更,为了不影响原来的页面。建议在发布时带上组件的版本号:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 组件有两个版本,在左侧组件列表区使用时就可以带上版本号:

{
  component: 'v-text',
  version: 'v1'
  ...
}

这样导入组件时就可以根据组件版本号进行导入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {
    Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

参考资料

查看原文

蒋鹏飞 赞了文章 · 1月18日

可视化拖拽组件库一些技术要点原理分析(二)

本文是对《可视化拖拽组件库一些技术要点原理分析》的补充。上一篇文章主要讲解了以下几个功能点:

  1. 编辑器
  2. 自定义组件
  3. 拖拽
  4. 删除组件、调整图层层级
  5. 放大缩小
  6. 撤消、重做
  7. 组件属性设置
  8. 吸附
  9. 预览、保存代码
  10. 绑定事件
  11. 绑定动画
  12. 导入 PSD
  13. 手机模式

现在这篇文章会在此基础上再补充 4 个功能点,分别是:

  • 拖拽旋转
  • 复制粘贴剪切
  • 数据交互
  • 发布

和上篇文章一样,我已经将新功能的代码更新到了 github:

友善提醒:建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。

14. 拖拽旋转

在写上一篇文章时,原来的 DEMO 已经可以支持旋转功能了。但是这个旋转功能还有很多不完善的地方:

  1. 不支持拖拽旋转。
  2. 旋转后的放大缩小不正确。
  3. 旋转后的自动吸附不正确。
  4. 旋转后八个可伸缩点的光标不正确。

这一小节,我们将逐一解决这四个问题。

拖拽旋转

拖拽旋转需要使用 Math.atan2() 函数。

Math.atan2() 返回从原点(0,0)到(x,y)点的线段与x轴正方向之间的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相对于圆点(0,0)的距离。

简单的说就是以组件中心点为原点 (centerX,centerY),用户按下鼠标时的坐标设为 (startX,startY),鼠标移动时的坐标设为 (curX,curY)。旋转角度可以通过 (startX,startY)(curX,curY) 计算得出。

那我们如何得到从点 (startX,startY) 到点 (curX,curY) 之间的旋转角度呢?

第一步,鼠标点击时的坐标设为 (startX,startY)

const startY = e.clientY
const startX = e.clientX

第二步,算出组件中心点:

// 获取组件中心点位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

第三步,按住鼠标移动时的坐标设为 (curX,curY)

const curX = moveEvent.clientX
const curY = moveEvent.clientY

第四步,分别算出 (startX,startY)(curX,curY) 对应的角度,再将它们相减得出旋转的角度。另外,还需要注意的就是 Math.atan2() 方法的返回值是一个弧度,因此还需要将弧度转化为角度。所以完整的代码为:

// 旋转前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋转后的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 获取旋转的角度值, startRotate 为初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

放大缩小

组件旋转后的放大缩小会有 BUG。

从上图可以看到,放大缩小时会发生移位。另外伸缩的方向和我们拖动的方向也不对。造成这一 BUG 的原因是:当初设计放大缩小功能没有考虑到旋转的场景。所以无论旋转多少角度,放大缩小仍然是按没旋转时计算的。

下面再看一个具体的示例:

从上图可以看出,在没有旋转时,按住顶点往上拖动,只需用 y2 - y1 就可以得出拖动距离 s。这时将组件原来的高度加上 s 就能得出新的高度,同时将组件的 topleft 属性更新。

现在旋转 180 度,如果这时拖住顶点往下拖动,我们期待的结果是组件高度增加。但这时计算的方式和原来没旋转时是一样的,所以结果和我们期待的相反,组件的高度将会变小(如果不理解这个现象,可以想像一下没有旋转的那张图,按住顶点往下拖动)。

如何解决这个问题呢?我从 github 上的一个项目 snapping-demo 找到了解决方案:将放大缩小和旋转角度关联起来。

解决方案

下面是一个已旋转一定角度的矩形,假设现在拖动它左上方的点进行拉伸。

现在我们将一步步分析如何得出拉伸后的组件的正确大小和位移。

第一步,按下鼠标时通过组件的坐标(无论旋转多少度,组件的 topleft 属性不变)和大小算出组件中心点:

const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}

第二步,用当前点击坐标和组件中心点算出当前点击坐标的对称点坐标:

// 获取画布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()

// 当前点击坐标
const curPoint = {
    x: e.clientX - editorRectInfo.left,
    y: e.clientY - editorRectInfo.top,
}

// 获取对称点的坐标
const symmetricPoint = {
    x: center.x - (curPoint.x - center.x),
    y: center.y - (curPoint.y - center.y),
}

第三步,摁住组件左上角进行拉伸时,通过当前鼠标实时坐标和对称点计算出新的组件中心点:

const curPositon = {
    x: moveEvent.clientX - editorRectInfo.left,
    y: moveEvent.clientY - editorRectInfo.top,
}

const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)

// 求两点之间的中点坐标
function getCenterPoint(p1, p2) {
    return {
        x: p1.x + ((p2.x - p1.x) / 2),
        y: p1.y + ((p2.y - p1.y) / 2),
    }
}

由于组件处于旋转状态,即使你知道了拉伸时移动的 xy 距离,也不能直接对组件进行计算。否则就会出现 BUG,移位或者放大缩小方向不正确。因此,我们需要在组件未旋转的情况下对其进行计算。

第四步,根据已知的旋转角度、新的组件中心点、当前鼠标实时坐标可以算出当前鼠标实时坐标currentPosition 在未旋转时的坐标 newTopLeftPoint。同时也能根据已知的旋转角度、新的组件中心点、对称点算出组件对称点sPoint 在未旋转时的坐标 newBottomRightPoint

对应的计算公式如下:

/**
 * 计算根据圆心旋转后的点的坐标
 * @param   {Object}  point  旋转前的点坐标
 * @param   {Object}  center 旋转中心
 * @param   {Number}  rotate 旋转的角度
 * @return  {Object}         旋转后的坐标
 * https://www.zhihu.com/question/67425734/answer/252724399 旋转矩阵公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋转公式:
     *  点a(x, y)
     *  旋转中心c(x, y)
     *  旋转后点n(x, y)
     *  旋转角度θ                tan ??
     * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
     * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
     */

    return {
        x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
        y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
    }
}

上面的公式涉及到线性代数中旋转矩阵的知识,对于一个没上过大学的人来说,实在太难了。还好我从知乎上的一个回答中找到了这一公式的推理过程,下面是回答的原文:

通过以上几个计算值,就可以得到组件新的位移值 topleft 以及新的组件大小。对应的完整代码如下:

function calculateLeftTop(style, curPositon, pointInfo) {
    const { symmetricPoint } = pointInfo
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
  
    const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
    const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (newWidth > 0 && newHeight > 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}

现在再来看一下旋转后的放大缩小:

自动吸附

自动吸附是根据组件的四个属性 topleftwidthheight 计算的,在将组件进行旋转后,这些属性的值是不会变的。所以无论组件旋转多少度,吸附时仍然按未旋转时计算。这样就会有一个问题,虽然实际上组件的 topleftwidthheight 属性没有变化。但在外观上却发生了变化。下面是两个同样的组件:一个没旋转,一个旋转了 45 度。

可以看出来旋转后按钮的 height 属性和我们从外观上看到的高度是不一样的,所以在这种情况下就出现了吸附不正确的 BUG。

解决方案

如何解决这个问题?我们需要拿组件旋转后的大小及位移来做吸附对比。也就是说不要拿组件实际的属性来对比,而是拿我们看到的大小和位移做对比。

从上图可以看出,旋转后的组件在 x 轴上的投射长度为两条红线长度之和。这两条红线的长度可以通过正弦和余弦算出,左边的红线用正弦计算,右边的红线用余弦计算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一样:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的宽度和高度有了,再根据组件原有的 topleft 属性,可以得出组件旋转后新的 topleft 属性。下面附上完整代码:

translateComponentStyle(style) {
    style = { ...style }
    if (style.rotate != 0) {
        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

经过修复后,吸附也可以正常显示了。

光标

光标和可拖动的方向不对,是因为八个点的光标是固定设置的,没有随着角度变化而变化。

解决方案

由于 360 / 8 = 45,所以可以为每一个方向分配 45 度的范围,每个范围对应一个光标。同时为每个方向设置一个初始角度,也就是未旋转时组件每个方向对应的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八个方向
initialAngle: { // 每个点对应的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每个范围的角度对应的光标
    { start: 338, end: 23, cursor: 'nw' },
    { start: 23, end: 68, cursor: 'n' },
    { start: 68, end: 113, cursor: 'ne' },
    { start: 113, end: 158, cursor: 'e' },
    { start: 158, end: 203, cursor: 'se' },
    { start: 203, end: 248, cursor: 's' },
    { start: 248, end: 293, cursor: 'sw' },
    { start: 293, end: 338, cursor: 'w' },
],
cursors: {},

计算方式也很简单:

  1. 假设现在组件已旋转了一定的角度 a。
  2. 遍历八个方向,用每个方向的初始角度 + a 得出现在的角度 b。
  3. 遍历 angleToCursor 数组,看看 b 在哪一个范围中,然后将对应的光标返回。

经常上面三个步骤就可以计算出组件旋转后正确的光标方向。具体的代码如下:

getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有负数,所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 从上一个命中的角度的索引开始匹配下一个,降低时间复杂度
    pointList.forEach(point => {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start <= angle && angle < angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},

从上面的动图可以看出来,现在八个方向上的光标是可以正确显示的。

15. 复制粘贴剪切

相对于拖拽旋转功能,复制粘贴就比较简单了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown && e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}

监听用户的按键操作,在按下特定按键时触发对应的操作。

复制操作

在 vuex 中使用 copyData 来表示复制的数据。当用户按下 ctrl + c 时,将当前组件数据深拷贝到 copyData

copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同时需要将当前组件在组件数据中的索引记录起来,在剪切中要用到。

粘贴操作

paste(state, isMouse) {
    if (!state.copyData) {
        toast('请选择组件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data })
    store.commit('recordSnapshot')
    state.copyData = null
},

粘贴时,如果是按键操作 ctrl+v。则将组件的 topleft 属性加 10,以免和原来的组件重叠在一起。如果是使用鼠标右键执行粘贴操作,则将复制的组件放到鼠标点击处。

剪切操作

cut(state) {
    if (!state.curComponent) {
        toast('请选择组件')
        return
    }

    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果当前组件索引大于等于插入索引,需要加一,因为当前组件往后移了一位
            state.curComponentIndex++
        }
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作本质上还是复制,只不过在执行复制后,需要将当前组件删除。为了避免用户执行剪切操作后,不执行粘贴操作,而是继续执行剪切。这时就需要将原先剪切的数据进行恢复。所以复制数据中记录的索引就起作用了,可以通过索引将原来的数据恢复到原来的位置中。

右键操作

右键操作和按键操作是一样的,一个功能两种触发途径。

<li @click="copy" v-show="curComponent">复制</li>
<li @click="paste">粘贴</li>
<li @click="cut" v-show="curComponent">剪切</li>

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},

16. 数据交互

方式一

提前写好一系列 ajax 请求API,点击组件时按需选择 API,选好 API 再填参数。例如下面这个组件,就展示了如何使用 ajax 请求向后台交互:

<template>
    <div>{{ propValue.data }}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res => {
            this.propValue.data = res.data
        })
    },
}
</script>

方式二

方式二适合纯展示的组件,例如有一个报警组件,可以根据后台传来的数据显示对应的颜色。在编辑页面的时候,可以通过 ajax 向后台请求页面能够使用的 websocket 数据:

const data = ['status', 'text'...]

然后再为不同的组件添加上不同的属性。例如有 a 组件,它绑定的属性为 status

// 组件能接收的数据
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},

在组件中通过 wsKey 获取这个绑定的属性。等页面发布后或者预览时,通过 weboscket 向后台请求全局数据放在 vuex 上。组件就可以通过 wsKey 访问数据了。

<template>
    <div>{{ wsData[wsKey] }}</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
</script>

和后台交互的方式有很多种,不仅仅包括上面两种,我在这里仅提供一些思路,以供参考。

17. 发布

页面发布有两种方式:一是将组件数据渲染为一个单独的 HTML 页面;二是从本项目中抽取出一个最小运行时 runtime 作为一个单独的项目。

这里说一下第二种方式,本项目中的最小运行时其实就是预览页面加上自定义组件。将这些代码提取出来作为一个项目单独打包。发布页面时将组件数据以 JSON 的格式传给服务端,同时为每个页面生成一个唯一 ID。

假设现在有三个页面,发布页面生成的 ID 为 a、b、c。访问页面时只需要把 ID 带上,这样就可以根据 ID 获取每个页面对应的组件数据。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加载

如果自定义组件过大,例如有数十个甚至上百个。这时可以将自定义组件用 import 的方式导入,做到按需加载,减少首屏渲染时间:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {
    Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本发布

自定义组件有可能会有更新的情况。例如原来的组件使用了大半年,现在有功能变更,为了不影响原来的页面。建议在发布时带上组件的版本号:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 组件有两个版本,在左侧组件列表区使用时就可以带上版本号:

{
  component: 'v-text',
  version: 'v1'
  ...
}

这样导入组件时就可以根据组件版本号进行导入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {
    Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

参考资料

查看原文

赞 28 收藏 20 评论 0

蒋鹏飞 关注了用户 · 1月15日

SHERlocked93 @sherlocked93

来自南京的前端打字员,掘金优秀作者,慕课畅销专栏 <JavaScript 设计模式精讲> 作者,原创同步更新于 Github 个人博客 (求 star🤪 )

公众号 前端下午茶,欢迎关注 👏 ,分享前端相关的技术博客、精选文章,期待在这里和大家一起进步 ~

关注 402

蒋鹏飞 关注了用户 · 1月12日

joyqi @joyqi

我的生涯一片无悔,想起那天夕阳下的奔跑,那是我逝去的青春

关注 1340

蒋鹏飞 发布了文章 · 1月12日

前端也能学算法:由浅入深讲解动态规划

动态规划是一种常用的算法思想,很多朋友觉得不好理解,其实不然,如果掌握了他的核心思想,并且多多练习还是可以掌握的。下面我们由浅入深的来讲讲动态规划。

斐波拉契数列

首先我们来看看斐波拉契数列,这是一个大家都很熟悉的数列:

// f = [1, 1, 2, 3, 5, 8]
f(1) = 1;
f(2) = 1;
f(n) = f(n-1) + f(n -2); // n > 2

有了上面的公式,我们很容易写出计算f(n)的递归代码:

function fibonacci_recursion(n) {
  if(n === 1 || n === 2) {
    return 1;
  }
  
  return fibonacci_recursion(n - 1) + fibonacci_recursion(n - 2);
}

const res = fibonacci_recursion(5);
console.log(res);   // 5

现在我们考虑一下上面的计算过程,计算f(5)的时候需要f(4)与f(3)的值,计算f(4)的时候需要f(3)与f(2)的值,这里f(3)就重复算了两遍。在我们已知f(1)和f(2)的情况下,我们其实只需要计算f(3),f(4),f(5)三次计算就行了,但是从下图可知,为了计算f(5),我们总共计算了8次其他值,里面f(3), f(2), f(1)都有多次重复计算。如果n不是5,而是一个更大的数,计算次数更是指数倍增长,这个递归算法的时间复杂度是$O(2^n)$。

image-20200121174402790

非递归的斐波拉契数列

为了解决上面指数级的时间复杂度,我们不能用递归算法了,而要用一个普通的循环算法。应该怎么做呢?我们只需要加一个数组,里面记录每一项的值就行了,为了让数组与f(n)的下标相对应,我们给数组开头位置填充一个0

const res = [0, 1, 1];
f(n) = res[n];

我们需要做的就是给res数组填充值,然后返回第n项的值就行了:

function fibonacci_no_recursion(n) {
  const res = [0, 1, 1];
  for(let i = 3; i <= n; i++){
    res[i] = res[i-1] + res[i-2];
  }
  
  return res[n];
}

const num = fibonacci_no_recursion(5);
console.log(num);   // 5

上面的方法就没有重复计算的问题,因为我们把每次的结果都存到一个数组里面了,计算f(n)的时候只需要将f(n-1)和f(n-2)拿出来用就行了,因为是从小往大算,所以f(n-1)和f(n-2)的值之前就算好了。这个算法的时间复杂度是O(n),比$O(2^n)$好的多得多。这个算法其实就用到了动态规划的思想。

动态规划

动态规划主要有如下两个特点

  1. 最优子结构:一个规模为n的问题可以转化为规模比他小的子问题来求解。换言之,f(n)可以通过一个比他规模小的递推式来求解,在前面的斐波拉契数列这个递推式就是f(n) = f(n-1) + f(n -2)。一般具有这种结构的问题也可以用递归求解,但是递归的复杂度太高。
  2. 子问题的重叠性:如果用递归求解,会有很多重复的子问题,动态规划就是修剪了重复的计算来降低时间复杂度。但是因为需要存储中间状态,空间复杂度是增加了。

其实动态规划的难点是归纳出递推式,在斐波拉契数列中,递推式是已经给出的,但是更多情况递推式是需要我们自己去归纳总结的。

钢条切割问题

image-20200121181228767

先看看暴力穷举怎么做,以一个长度为5的钢条为例:

image-20200121182429181

上图红色的位置表示可以下刀切割的位置,每个位置可以有切和不切两种状态,总共是$2^4 = 16$种,对于长度为n的钢条,这个情况就是$2^{n-1}$种。穷举的方法就不写代码了,下面直接来看递归的方法:

递归方案

还是以上面那个长度为5的钢条为例,假如我们只考虑切一刀的情况,这一刀的位置可以是1,2,3,4中的任意位置,那切割之后,左右两边的长度分别是:

// [left, right]: 表示切了后左边,右边的长度
[1, 4]: 切1的位置
[2, 3]: 切2的位置
[3, 2]: 切3的位置
[4, 1]: 切4的位置

分成了左右两部分,那左右两部分又可以继续切,每部分切一刀,又变成了两部分,又可以继续切。这不就将一个长度为5的问题,分解成了4个小问题吗,那最优的方案就是这四个小问题里面最大的那个值,同时不要忘了我们也可以一刀都不切,这是第五个小问题,我们要的答案其实就是这5个小问题里面的最大值。写成公式就是,对于长度为n的钢条,最佳收益公式是:

image-20200122135927576

  • $r_n$ : 表示我们求解的目标,长度为n的钢条的最大收益
  • $p_n$: 表示钢条完全不切的情况
  • $r_1 + r_{n-1}$: 表示切在1的位置,分为了左边为1,右边为n-1长度的两端,他们的和是这种方案的最优收益
  • 我们的最大收益就是不切和切在不同情况的子方案里面找最大值

上面的公式已经可以用递归求解了:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod(n) {
  if(n === 1) return 1;
  
  let max = p[n];
  for(let i = 1; i < n; i++){
    let sum = cut_rod(i) + cut_rod(n - i);
    if(sum > max) {
      max = sum;
    }
  }
  
  return max;
}

cut_rod(9);  // 返回 25

上面的公式还可以简化,假如我们长度9的最佳方案是切成2 3 2 2,用前面一种算法,第一刀将它切成2 75 4,然后两边再分别切最终都可以得到2 3 2 2,所以5 4方案最终结果和2 7方案是一样的,都会得到2 3 2 2,如果这两种方案,两边都继续切,其实还会有重复计算。那长度为9的切第一刀,左边的值肯定是1 -- 9,我们从1依次切过来,如果后面继续对左边的切割,那继续切割的那个左边值必定是我们前面算过的一个左边值。比如5 4切割成2 3 4,其实等价于第一次切成2 7,第一次如果是3 6,如果继续切左边,切为1 2 6,其实等价于1 8,都是前面切左边为1的时候算过的。所以如果我们左边依次是从1切过来的,那么就没有必要再切左边了,只需要切右边。所以我们的公式可以简化为:

$$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$

继续用递归实现这个公式:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod2(n) {
  if(n === 1) return 1;
  
  let max = p[n];
  for(let i = 1; i <= n; i++){
    let sum = p[i] + cut_rod2(n - i);
    if(sum > max) {
      max = sum;
    }
  }
  
  return max;
}

cut_rod2(9);  // 结果还是返回 25

上面的两个公式都是递归,复杂度都是指数级的,下面我们来讲讲动态规划的方案。

动态规划方案

动态规划方案的公式和前面的是一样的,我们用第二个简化了的公式:

$$ r_n = \max_{1<=i<=n}(pi+r_{n-i}) $$

动态规划就是不用递归,而是从底向上计算值,每次计算上面的值的时候,下面的值算好了,直接拿来用就行。所以我们需要一个数组来记录每个长度对应的最大收益。

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod3(n) {
  let r = [0, 1];   // r数组记录每个长度的最大收益
  
  for(let i = 2; i <=n; i++) {
    let max = p[i];
    for(let j = 1; j <= i; j++) {
      let sum = p[j] + r[i - j];
      
      if(sum > max) {
        max = sum;
      }
    }
    
    r[i] = max;
  }
  
  console.log(r);
  return r[n];
}

cut_rod3(9);  // 结果还是返回 25

我们还可以把r数组也打出来看下,这里面存的是每个长度对应的最大收益:

r = [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]

使用动态规划将递归的指数级复杂度降到了双重循环,即$O(n^2)$的复杂度。

输出最佳方案

上面的动态规划虽然计算出来最大值,但是我们并不是知道这个最大值对应的切割方案是什么,为了知道这个方案,我们还需要一个数组来记录切割一次时左边的长度,然后在这个数组中回溯来找出切割方案。回溯的时候我们先取目标值对应的左边长度,然后右边剩下的长度又继续去这个数组找最优方案对应的左边切割长度。假设我们左边记录的数组是:

leftLength = [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]

我们要求长度为9的钢条的最佳切割方案:

1. 找到leftLength[9], 发现值为3,记录下3为一次切割
2. 左边切了3之后,右边还剩6,又去找leftLength[6],发现值为6,记录下6为一次切割长度
3. 又切了6之后,发现还剩0,切完了,结束循环;如果还剩有钢条继续按照这个方式切
4. 输出最佳长度为[3, 6]

改造代码如下:

const p = [0, 1, 5, 8, 9, 10, 17, 17, 20, 24, 30]; // 下标表示钢条长度,值表示对应价格

function cut_rod3(n) {
  let r = [0, 1];   // r数组记录每个长度的最大收益
  let leftLength = [0, 1];  // 数组leftLength记录切割一次时左边的长度
  let solution = [];
  
  for(let i = 2; i <=n; i++) {
    let max = p[i];
    leftLength[i] = i;     // 初始化左边为整块不切
    for(let j = 1; j <= i; j++) {
      let sum = p[j] + r[i - j];
      
      if(sum > max) {
        max = sum;
        leftLength[i] = j;  // 每次找到大的值,记录左边的长度
      } 
    }
    
    r[i] = max;
  }
  
  // 回溯寻找最佳方案
  let tempN = n;
  while(tempN > 0) {
    let left = leftLength[tempN];
    solution.push(left);
    tempN = tempN - left;
  }
  
  console.log(leftLength);  // [0, 1, 2, 3, 2, 2, 6, 1, 2, 3]
  console.log(solution);    // [3, 6]
  console.log(r);           // [0, 1, 5, 8, 10, 13, 17, 18, 22, 25]
  return {max: r[n], solution: solution};
}

cut_rod3(9);  // {max: 25, solution: [3, 6]}

最长公共子序列(LCS)

image-20200202214347127

上叙问题也可以用暴力穷举来求解,先列举出X字符串所有的子串,假设他的长度为m,则总共有$2^m$种情况,因为对于X字符串中的每个字符都有留着和不留两种状态,m个字符的全排列种类就是$2^m$种。那对应的Y字符串就有$2^n$种子串, n为Y的长度。然后再遍历找出最长的公共子序列,这个复杂度非常高,我这里就不写了。

我们观察两个字符串,如果他们最后一个字符相同,则他们的LCS(最长公共子序列简写)就是两个字符串都去掉最后一个字符的LCS再加一。因为最后一个字符相同,所以最后一个字符是他们的子序列,把他去掉,子序列就少了一个,所以他们的LCS是他们去掉最后一个字符的字符串的LCS再加一。如果他们最后一个字符不相同,那他们的LCS就是X去掉最后一个字符与Y的LCS,或者是X与Y去掉最后一个字符的LCS,是他们两个中较长的那一个。写成数学公式就是:

image-20200202220405084

看着这个公式,一个规模为(i, j)的问题转化为了规模为(i-1, j-1)的问题,这不就又可以用递归求解了吗?

递归方案

公式都有了,不废话,直接写代码:

function lcs(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  if(length1 === 0 || length2 === 0) {
    return 0;
  }
  
  let shortStr1 = str1.slice(0, -1);
  let shortStr2 = str2.slice(0, -1);
  if(str1[length1 - 1] === str2[length2 -  1]){
    return lcs(shortStr1, shortStr2) + 1;
  } else {
    let lcsShort2 = lcs(str1, shortStr2);
    let lcsShort1 = lcs(shortStr1, str2);
    
    return lcsShort1 > lcsShort2 ? lcsShort1 : lcsShort2;
  }
}

let result = lcs('ABBCBDE', 'DBBCD');
console.log(result);   // 4

动态规划

递归虽然能实现我们的需求,但是复杂度是在太高,长一点的字符串需要的时间是指数级增长的。我们还是要用动态规划来求解,根据我们前面讲的动态规划原理,我们需要从小的往大的算,每算出一个值都要记下来。因为c(i, j)里面有两个变量,我们需要一个二维数组才能存下来。注意这个二维数组的行数是X的长度加一,列数是Y的长度加一,因为第一行和第一列表示X或者Y为空串的情况。代码如下:

function lcs2(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  // 构建一个二维数组
  // i表示行号,对应length1 + 1
  // j表示列号, 对应length2 + 1
  // 第一行和第一列全部为0
  let result = [];
  for(let i = 0; i < length1 + 1; i++){
    result.push([]); //初始化每行为空数组
    for(let j = 0; j < length2 + 1; j++){
      if(i === 0) {
        result[i][j] = 0; // 第一行全部为0
      } else if(j === 0) {
        result[i][j] = 0; // 第一列全部为0
      } else if(str1[i - 1] === str2[j - 1]){
        // 最后一个字符相同
        result[i][j] = result[i - 1][j - 1] + 1;
      } else{
        // 最后一个字符不同
        result[i][j] = result[i][j - 1] > result[i - 1][j] ? result[i][j - 1] : result[i - 1][j];
      }
    }
  }
  
  console.log(result);
  return result[length1][length2]
}

let result = lcs2('ABCBDAB', 'BDCABA');
console.log(result);   // 4

上面的result就是我们构造出来的二维数组,对应的表格如下,每一格的值就是c(i, j),如果$X_i = Y_j$,则它的值就是他斜上方的值加一,如果$X_i \neq Y_i$,则它的值是上方或者左方较大的那一个。

image-20200202224206267

输出最长公共子序列

要输出LCS,思路还是跟前面切钢条的类似,把每一步操作都记录下来,然后再回溯。为了记录操作我们需要一个跟result二维数组一样大的二维数组,每个格子里面的值是当前值是从哪里来的,当然,第一行和第一列仍然是0。每个格子的值要么从斜上方来,要么上方,要么左方,所以:

1. 我们用1来表示当前值从斜上方来
2. 我们用2表示当前值从左方来
3. 我们用3表示当前值从上方来

看代码:

function lcs3(str1, str2) {
  let length1 = str1.length;
  let length2 = str2.length;
  
  // 构建一个二维数组
  // i表示行号,对应length1 + 1
  // j表示列号, 对应length2 + 1
  // 第一行和第一列全部为0
  let result = [];
  let comeFrom = [];   // 保存来历的数组
  for(let i = 0; i < length1 + 1; i++){
    result.push([]); //初始化每行为空数组
    comeFrom.push([]);
    for(let j = 0; j < length2 + 1; j++){
      if(i === 0) {
        result[i][j] = 0; // 第一行全部为0
        comeFrom[i][j] = 0;
      } else if(j === 0) {
        result[i][j] = 0; // 第一列全部为0
        comeFrom[i][j] = 0;
      } else if(str1[i - 1] === str2[j - 1]){
        // 最后一个字符相同
        result[i][j] = result[i - 1][j - 1] + 1;
        comeFrom[i][j] = 1;      // 值从斜上方来
      } else if(result[i][j - 1] > result[i - 1][j]){
        // 最后一个字符不同,值是左边的大
        result[i][j] = result[i][j - 1];
        comeFrom[i][j] = 2;
      } else {
        // 最后一个字符不同,值是上边的大
        result[i][j] = result[i - 1][j];
        comeFrom[i][j] = 3;
      }
    }
  }
  
  console.log(result);
  console.log(comeFrom);
  
  // 回溯comeFrom数组,找出LCS
  let pointerI = length1;
  let pointerJ = length2;
  let lcsArr = [];   // 一个数组保存LCS结果
  while(pointerI > 0 && pointerJ > 0) {
    console.log(pointerI, pointerJ);
    if(comeFrom[pointerI][pointerJ] === 1) {
      lcsArr.push(str1[pointerI - 1]);
      pointerI--;
      pointerJ--;
    } else if(comeFrom[pointerI][pointerJ] === 2) {
      pointerI--;
    } else if(comeFrom[pointerI][pointerJ] === 3) {
      pointerJ--;
    }
  }
  
  console.log(lcsArr);   // ["B", "A", "D", "B"]
  //现在lcsArr顺序是反的
  lcsArr = lcsArr.reverse();
  
  return {
    length: result[length1][length2], 
    lcs: lcsArr.join('')
  }
}

let result = lcs3('ABCBDAB', 'BDCABA');
console.log(result);   // {length: 4, lcs: "BDAB"}

最短编辑距离

这是leetcode上的一道题目,题目描述如下:

image-20200209114557615

这道题目的思路跟前面最长公共子序列非常像,我们同样假设第一个字符串是$X=(x_1, x_2 ... x_m)$,第二个字符串是$Y=(y_1, y_2 ... y_n)$。我们要求解的目标为$r$, $r[i][j]$为长度为$i$的$X$和长度为$j$的$Y$的解。我们同样从两个字符串的最后一个字符开始考虑:

  1. 如果他们最后一个字符是一样的,那最后一个字符就不需要编辑了,只需要知道他们前面一个字符的最短编辑距离就行了,写成公式就是:如果$Xi = Y_j$,$r[i][j] = r[i-1][j-1]$。
  2. 如果他们最后一个字符是不一样的,那最后一个字符肯定需要编辑一次才行。那最短编辑距离就是$X$去掉最后一个字符与$Y$的最短编辑距离,再加上最后一个字符的一次;或者是是$Y$去掉最后一个字符与$X$的最短编辑距离,再加上最后一个字符的一次,就看这两个数字哪个小了。这里需要注意的是$X$去掉最后一个字符或者$Y$去掉最后一个字符,相当于在$Y$上进行插入和删除,但是除了插入和删除两个操作外,还有一个操作是替换,如果是替换操作,并不会改变两个字符串的长度,替换的时候,距离为$r[i][j]=r[i-1][j-1]+1$。最终是在这三种情况里面取最小值,写成数学公式就是:如果$Xi \neq Y_j$,$r[i][j] = \min(r[i-1][j], r[i][j-1],r[i-1][j-1]) + 1$。
  3. 最后就是如果$X$或者$Y$有任意一个是空字符串,那为了让他们一样,就往空的那个插入另一个字符串就行了,最短距离就是另一个字符串的长度。数学公式就是:如果$i=0$,$r[i][j] = j$;如果$j=0$,$r[i][j] = i$。

上面几种情况总结起来就是

$$ r[i][j]= \begin{cases} j, & \text{if}\ i=0 \\ i, & \text{if}\ j=0 \\ r[i-1][j-1], & \text{if}\ X_i=Y_j \\ \min(r[i-1][j], r[i][j-1], r[i-1][j-1]) + 1, & \text{if} \ X_i\neq Y_j \end{cases} $$

递归方案

老规矩,有了递推公式,我们先来写个递归:

const minDistance = function(str1, str2) {
    const length1 = str1.length;
    const length2 = str2.length;

    if(!length1) {
        return length2;
    }

    if(!length2) {
        return length1;
    }

    const shortStr1 = str1.slice(0, -1);
    const shortStr2 = str2.slice(0, -1); 

    const isLastEqual = str1[length1-1] === str2[length2-1];

    if(isLastEqual) {
        return minDistance(shortStr1, shortStr2);
    } else {
        const shortStr1Cal = minDistance(shortStr1, str2);
        const shortStr2Cal = minDistance(str1, shortStr2);
        const updateCal = minDistance(shortStr1, shortStr2);

        const minShort = shortStr1Cal <= shortStr2Cal ? shortStr1Cal : shortStr2Cal;
        const minDis = minShort <= updateCal ? minShort : updateCal;

        return minDis + 1;
    }
}; 

//测试一下
let result = minDistance('horse', 'ros');
console.log(result);  // 3

result = minDistance('intention', 'execution');
console.log(result);  // 5

动态规划

上面的递归方案提交到leetcode会直接超时,因为复杂度太高了,指数级的。还是上我们的动态规划方案吧,跟前面类似,需要一个二维数组来存放每次执行的结果。

const minDistance = function(str1, str2) {
    const length1 = str1.length;
    const length2 = str2.length;

    if(!length1) {
        return length2;
    }

    if(!length2) {
        return length1;
    }

    // i 为行,表示str1
    // j 为列,表示str2
    const r = [];
    for(let i = 0; i < length1 + 1; i++) {
        r.push([]);
        for(let j = 0; j < length2 + 1; j++) {
            if(i === 0) {
                r[i][j] = j;
            } else if (j === 0) {
                r[i][j] = i;
            } else if(str1[i - 1] === str2[j - 1]){ // 注意下标,i,j包括空字符串,长度会大1
                r[i][j] = r[i - 1][j - 1];
            } else {
                r[i][j] = Math.min(r[i - 1][j ], r[i][j - 1], r[i - 1][j - 1]) + 1;
            }
        }
    }

    return r[length1][length2];
};

//测试一下
let result = minDistance('horse', 'ros');
console.log(result);  // 3

result = minDistance('intention', 'execution');
console.log(result);  // 5

上述代码因为是双重循环,所以时间复杂度是$O(mn)$。

总结

动态规划的关键点是要找出递推式,有了这个递推式我们可以用递归求解,也可以用动态规划。用递归时间复杂度通常是指数级增长,所以我们有了动态规划。动态规划的关键点是从小往大算,将每一个计算记过的值都记录下来,这样我们计算大的值的时候直接就取到前面计算过的值了。动态规划可以大大降低时间复杂度,但是增加了一个存计算结果的数据结构,空间复杂度会增加。这也算是一种用空间换时间的策略了。

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 20 收藏 12 评论 0

蒋鹏飞 赞了文章 · 1月8日

思否有约丨@皮小蛋:深漂多年,只想早日上岸

访谈嘉宾:@皮小蛋
访谈编辑:芒果果


能不能接受 996?

回老家还是去大城市?

要去大企业还是小公司?

…...

年轻人面临的艰难选择太多了,每一个不同的决定都有可能带你走上完全不同的人生轨迹。

image

行业内卷严重,新人如何才能出头?

27 岁的皮小蛋是个“深漂”,从 2016 年毕业后,他就只身到了深圳。经历了几年“社会的毒打”后,皮小蛋现在已经可以坐在面试官的岗位考核即将入职的新员工了。

皮小蛋说,“深圳, 是一座种充满活力的城市。 竞争很大, 但机会也很多。其他方面,感觉都挺好的, 就是房价太高了。希望好好干几年,多多努力, 争取早日上岸。”

也许,皮小蛋这种状态就是很多年轻人梦寐以求的,能在大城市有一份体面的工作,打拼几年后再买一套房子,站稳脚跟。

但每个人的情况不同,职业选择也会不同。互联网行业发展的越来越快,投身到IT行业的人越来越多,个人想要做的出彩,出人头地, 确实比较难。而且,企业对人才的要求也在不断变化, 门槛越来越高。程序员行业的“内卷”愈来愈严重,比如 996、11 12 7 的工作制,和随处可见的倒挂等等。

作为有一定经验的面试官,皮小蛋也提出了自己的看法。

对研发而言,主要是两类人比较吃香:

  1. 有潜力, 爱学习,态度好的初中级工程师。
  2. 经验丰富,独当一面, 具备一定管理能力的高级工程师。

对个人而言,有几点非常重要:

  1. 保持学习,提升专业素质,形成自己的核心竞争力。
  2. 保持一个好的心态, 延迟满足感。
  3. 不要给自己设限, 你可以是专业的工程师, 也可以是魔术师, 投资小能手。

用代码行数考核绩效?工作要有自己的准则

当然了,在工作当中皮小蛋也经历过一些令他怀疑人生的瞬间。当听到老板说要通过代码行数来衡量程序员的绩效的那一刻,他的脑子里闪出了一行字“还有这种操作?”

原以为只是段子里的事情居然真的发生了,而且就发生在自己身边,这让皮小蛋和他的同事都无法接受。程序员这个职业不能单纯通过代码行数来衡量业绩,一些算法优化可能就是调调参数, 并没有多少代码, 不能作为绩效考核的依据。好在在大家强烈的反对下,用代码行数考核业绩的方式最终没有实行。

对待工作,皮小蛋有自己的一条准则:及时、紧凑、可预判、避免惊喜。具体到工作事项中,就是利用碎片化的时间用来处理消息、邮件,同步进度,整块的时间用来开会、编码等需要投入的事情。同时,每件事的进度也要有一套标准来进行管理。

寻找爱好也是一种疏解压力的方式,别让生活太紧绷

工作不是全部,奋斗也是为了生活,压力太大时也需要放松身心。皮小蛋养了一只叫“皮蛋”的小猫,这也是他笔名的来源。平时,他会和皮蛋一起玩,或者在家看书、弹钢琴,偶尔也会骑上山地车来一次短途骑行。

image.png

寻找爱好也是一种疏解压力的方式。大学毕业前夕,在所有人都忙着考研或者找工作的时候,皮小蛋却用这段时间开始学起了钢琴。那是一个普通的周末,正在路口等车的皮小蛋突然听到了一段悠扬的旋律,他瞬间就被吸引了,车也不等了,就开始顺着声音寻找。

最后,在一个角落里的二楼找到了一家琴行,皮小蛋想都没想就当场交钱开始了学琴生涯。毕业的第二年,他已经考到了中央音乐学院的 3 级钢琴证书。

到现在,他都会在每天午饭后到公司楼下去练一会儿琴。弹钢琴既是他的长期爱好,也是他舒缓解压的方式之一。


无论选择在大城市还是回老家,去大企业还是小公司,最重要的是有想法、有能力、意愿,在自己的岗位上发挥最大的能力。

但同时,也别忘了生活,可以学个乐器、养个宠物、甚至只是去公园里散散步,为生活添点生气和乐趣。

希望每一个“漂着”的年轻人都能早日“上岸”。


欢迎有兴趣参与访谈的小伙伴踊跃报名,《思否有约》将把你与编程有关的故事记录下来。报名邮箱:mango@sifou.com

segmentfault 公众号

查看原文

赞 11 收藏 1 评论 1

蒋鹏飞 关注了用户 · 1月7日

木马啊 @wintc

想把代码写成诗的未成名作家。

欢迎交流技术问题。
微信:win_tc

个人网站: https://wintc.top

思否冬眠中... 2021/02/01【不知道啥时候回来】

关注 123

蒋鹏飞 关注了用户 · 1月7日

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2173

蒋鹏飞 赞了文章 · 1月6日

而立之年——回顾我的前端转行之路

为什么转行

因为混得不好。

在成为程序员之前,我干过很多工作。由于学历的问题(高中),我的工作基本上都是体力活。包括但不限于:工厂普工、销售(没有干销售的才能)、搬运工、摆地摊等,转行前最后一份工作是修电脑。这么多年,月薪没高过 3300...

后来偶然一个机会我发现了知乎这个网站,在上面了解到程序员的各种优点。于是,我下定决心转行(2016 年,当时 28 了),辞职在家自学编程。并且也得到了媳妇的支持,感谢我的媳妇。

转行准备

转行选择前端也是在知乎上看网友分析的,比后端好入门。

如何选择教程?

最好在网上多查查资料,找评价高的或者去豆瓣上找评分高的书。

我在网上查了很多资料,最终确定 HTML、CSS 在 w3cschool 学习。JavaScript 则选择了JavaScript 高级程序设计第三版(俗称红宝书,现在已经有第四版了)。

光看不练是学不好编程的,我非常幸运的遇到了百度前端技术学院。它从易到难设置了 52 个任务,共分为四个阶段。任务难度循序渐进,每一个任务都有清晰的讲解和学习参考资料。它还怕你不会做,允许你查看其他人上传的任务答案。

我先学习了 HTML、CSS,做完了第一阶段任务。再看完红宝书前十三章,做完了第二阶段任务。然后把红宝石剩下的全看完,做到第三阶段的任务四十五。后面的任务对于当时的我来说实在太难了,就没往下做。在 1 月的时候,又学习了 ajax,了解了前后端如何相互通信。

我从 16 年 11 月开始自学前端,一直到 17 年 2 月。历时 3 个月,平均每天学习 3-4 个小时。中间有好几次因为太难想过放弃,不过最后还是坚持下来了。

找工作的过程非常艰难,我在网上各大招聘平台投了很多简历,但由于没学历、没经验,所以一个回复都没有。最后还是我媳妇工作的公司在招前端,给了我一个内推的机会,才有了第一次面试。并且第一次面试也很顺利,居然过了,这是我没想到的。直到多年后我和面试官又在一个公司的时候,才知道原因。他的意思是:看在我这么努力自学编程的份上,愿意给我一个机会。

虽然人生很艰难,但很有可能,遇到一个愿意给你机会的人,就能改变你的命运。

正式工作

第一年

在正式的项目中写代码和在学习时写代码是不一样的。你必须得考虑这样写安不安全,会不会引起 BUG,会不会引起性能问题。在工作的第一年,写业务代码对我的提升非常大。

第一年的主要任务,就是提升前端基础能力。因此我看了很多 JavaScript 的书籍来提升自己的水平:

  1. JavaScript高级程序设计(第三版)
  2. 高性能JavaScript
  3. JavaScript语言精粹
  4. 你不知道的JavaScript(上中下三卷)
  5. ES6标准入门
  6. 深入浅出Node.js

这些书都是非常经典的书籍,有几本我还看了好几篇。

除了看书外,我还做了百度前端技术学院 2017 年的任务,它比 2016 年的任务(转行时做的是 2016 年的任务)更有难度和深度,非常适合进阶。

另外还学习了 jquery 和 nodejs。jquery 是工作中要用,nodejs 则是出于兴趣学习的,没有多深入。

第二年

到了第二年,写业务代码对于我来说,已经提升不大了,就像一个熟练工一样。而且感觉前端方面掌握的知识已经足够把工作做好了。于是我就想,为了成为一名顶尖的程序员,还需要做什么。我在网上查了很多资料,看了很多前辈的回答,最后决定自学计算机专业。

我制定了一个自学计算机专业的计划,并且减少花在前端上的时间。因为说到底,基础是地基。基础打好了,楼才能建得高。

计算机系统要素

计算机系统要素是我制订计划后开始学习的第一本书。它主要讲解了计算机原理(1-5章)、编译原理(6-11章)、操作系统相关知识(12章)。不要看内容这么多,其实这本书的内容非常通俗易懂,翻译也很给力。每一章后面都有相关的实验,需要你手写代码去完成,堪称理论与实践结合的经典。

这里引用一下书里的简介,大家可以感受一下:

本书通过展现简单但功能强大的计算机系统之构建过程,为读者呈现了一幅完整、严格的计算机应用科学大图景。本书作者认为,理解计算机工作原理的最好方法就是亲自动手,从零开始构建计算机系统。

通过12个章节和项目来引领读者从头开始,本书逐步地构建一个基本的硬件平台和现代软件阶层体系。在这个过程中,读者能够获得关于硬件体系结构、操作系统、编程语言、编译器、数据结构、算法以及软件工程的详实知识。通过这种逐步构造的方法,本书揭示了计算机科学知识中的重要成分,并展示其它课程中所介绍的理论和应用技术如何融入这幅全局大图景当中去。

全书基于“先抽象再实现”的阐述模式,每一章都介绍一个关键的硬件或软件抽象,一种实现方式以及一个实际的项目。完成这些项目所必要的计算机科学知识在本书中都有涵盖,只要求读者具备程序设计经验。本书配套的支持网站提供了书中描述的用于构建所有硬件和软件系统所必需的工具和资料,以及用于12个项目的200个测试程序。

全书内容广泛、涉猎全面,适合计算机及相关专业本科生、研究生、技术开发人员、教师以及技术爱好者参考和学习。

做完这些实验,让我有了一个质的提升。以前感觉计算机就是一个黑盒,但现在不一样了。我开始了解计算机内部是如何运作的。明白了自己写的代码是怎么经过编译变成指令,最后在 CPU 中执行的。也明白了指令、数据怎么在 CPU 和内存之间流转的。

这本书所有实验的答案我都放在了 github 上,有兴趣不妨了解一下。

Vue

这一年还学会了 Vue。除了熟读文档外,还为了研究源码而模仿 Vue1.0 版本写了一个 mini-vue。不过学习源码对于我写业务代码并没有什么帮助。如果不是出于兴趣去研究源码,最好不要去学,熟读文档就能完全应付工作了。如果是为了面试,那也不需要阅读源码。只需要在网上找一些质量高的源码分析文章看一看,作一下笔记就 OK 了。

为什么我不建议阅读源码?因为阅读源码效率太低,而且相对于你的付出,收益并不大。到后面 Vue 出了 3.0 版本时,我也是有选择地阅读部分源码。

第三年

第三年有大半年的时间浪费在王者荣耀上,那会天天只想着冲荣耀,根本没心思学习。后来终于醒悟过来了,王者荣耀是我成为顶级程序员的阻碍。于是痛定思痛,给戒掉了。

由于打王者的原因,第三年没学习多少新知识。基本上只做了三件事:

  1. 写了几个 Vue 相关的插件和项目。
  2. 将过去所学的前端知识,整理了一下放在 github 上,有空就复习一下。
  3. 学习数据结构与算法。

数据结构与算法

数据结构和算法有什么用?学了算法后,我觉得至少会懂得去分析程序的性能问题。

一个程序的性能有问题,需要你去优化。如果学过数据结构和算法,你会从时间复杂度和空间复杂度去分析代码,然后解决问题。如果没学过,你只能靠猜、碰运气来解决问题。

理论知识上,我主要看的是算法这本书,课后习题没做,改成用刷 leetcode 代替。目前已经刷了 300+ 道题,还在继续刷。不过由于数学差,稍微复杂一点的算法知识都看不懂,效果不是很好。

第四年

第四年,也就是今年(2020),是我重新奋斗的一年。今年比以往的任何一年都要努力,每天保证 3 小时以上的学习时间。如果实在太忙了,达不到要求,那就改天把时间补上。附上我今年的学习时长图(记录软件为 Now Then):

今年我做了非常多的事情:

  1. 研究前端工程化。
  2. 学习操作系统。
  3. 学习计算机网络。
  4. 学习软件工程。
  5. 学习 C++。
  6. 学英语。

前端工程化

研究前端工程化的目的,就是为了提高团队的开发效率。为此我看了很多书和资料:

...

研究了一年的时间,写了一篇质量较高的入门教程——手把手带你入门前端工程化——超详细教程。除此之外,还有其他工程化相关的一系列文章:

操作系统

操作系统是管理计算机硬件与软件资源的计算机程序。通常情况下,程序是运行在操作系统上的,而不是直接和硬件交互。一个程序如果想和硬件交互就得通过操作系统。

如果你掌握了操作系统的知识,你就知道程序是怎么和硬件交互的。

例如你知道申请内存,释放内存的内部过程是怎样的;当你按下 k 键,你也知道 k 是怎么出现在屏幕上的;知道文件是怎么读出、写入的。

对于操作系统,我主要学习了以下书籍:

  1. x86汇编语言:从实模式到保护模式
  2. xv6-chinese
  3. 操作系统导论

然后做 MIT6.828 的实验,实现了一个简单的操作系统内核。

计算机网络

计算机网络的作用主要是解决计算机之间如何通信的问题。

例如 A 地区和 B 地区的的计算机怎么通信?同一局域网的两台电脑又如何通信?学习计算机网络知识就是了解它们是怎么通信的以及怎么将它们联通起来。

对于计算机网络,我主要学习了以下书籍:

  1. 计算机网络--自顶向下
  2. 计算机网络
  3. HTTP权威指南
  4. HTTP/2基础教程

并且做了计算机网络--自顶向下的实验。

软件工程

软件工程是一门研究用工程化方法构建和维护有效的、实用的和高质量的软件的学科。它涉及程序设计语言、数据库、软件开发工具、系统平台、标准、设计模式等方面。

学习以下书籍:

  1. 代码大全(第2版)
  2. 重构(第2版)
  3. 软件工程

软件工程是一门非常庞大的学科,我只学习了一点皮毛。主要学习的是关于代码怎么写得更好、结构组织更合理的知识,这需要一边学习一边在工作中运用。

C++

学习 C++ 其实是为了研究 nodejs 源码用的,看的这本书C++ Primer 中文版(第 5 版)

英语

我从转行开始就一直在学习英语,不过今年花的时间比较多。

英语对于程序员的好处非常非常多,就我知道的有:

  1. 可以用 google 和 stackoverflow 来解决问题。
  2. 知道怎么给变量、函数起一个好的命名。
  3. 很多流行的软件都是国外程序员写的,有问题你可以直接看文档以及和别人交流。

在我转行前英语词汇量只有几百,三年多过去了,现在词汇量有 6000(都是用百词斩测的)。

写作

写作的好处是非常多的,越早写越好。我还记得第一篇文章是 2017 年 2 月发表的,是我工作后的第 13 天,发表在 CSDN 上。

个人认为写作的好处有三点:

  1. 锻炼你的写作能力。一般情况下,写得越多,写作能力越好。这个好,不是说你的文章遣词造句有多好,而是指文章条理清晰,通俗易懂,容易让人理解。
  2. 写作其实是费曼学习法的运用,帮助自己加深理解所学的知识。有没有试过,学完一个知识点后,觉得自己懂了。但让你向别人讲述这个知识点时,反而吞吞吐吐不知道怎么讲。其实这是没理解透才会这样的,要让别人明白你在表达什么,首先你得非常熟悉这个知识点。一知半解是不可能把它讲明白的,所以写作也是在帮你梳理知识。
  3. 增加自己的曝光度。在我三年多的程序员生涯中,一共写了 50 多篇文章,因此在一些平台上也收获了不少赞和粉丝。因为我写的某些文章质量还行,不少大厂的程序员找过我,给我内推。不过由于个人学历问题,基本上都没下文...

总之一句话,写作对你只有好处,没有坏处。

学习

有选择的学习

我觉得学习一定要有非常清晰的目标,知道你要学什么,怎么学。对于前端来说,我认为很多框架和库都是不用学的。例如前端三大框架,没有必要三个都学,把你工作中要用的那个掌握好就行。

比如你公司用的是 Vue,就深入学习 Vue,如果要看源码就只看重点部分的源码。例如模板编译、Diff 算法、Vue 原生组件实现、指令实现等等。

剩下的两个框架 React、Angular 做个 DEMO 熟悉一下就行,毕竟原理都是相通的。等你公司要上这两个再深入学习,不过也不建议阅读源码了,太累。看别人写的现成的源码分析文章就好。

其他的,像 easyui、Backbone.js、各种小程序... 用不到的坚决不学,浪费时间。用的时候看文档就行了,当然,如果有兴趣了解如何实现也是可以的。

学习方法

我觉得好的学习方法非常重要,对我比较有用的两个是:

  1. 费曼学习法。
  2. 学习一个知识点,最好把它吃透。

费曼学习法在《写作》一节中已经说过了,这里着重说说第二个。

你有没有过这种感觉:觉得自己会的东西很多,但其实掌握的知识很多都停留在表面上,别人要是往深一问,就懵逼了。

我以前就有过这种感觉,主要问题出在对知识的学习仅停留在浅尝即止的状态。就是学习新知识,能写个 DEMO,就觉得自己学得差不多了。这种学习方法是很有害的,首先知识存留度不高,其次是浪费时间,因为很快就会忘掉。

后来我尝试改正这种状态,在学习新的知识点时,时常问自己三个问题:

  1. 这是什么?
  2. 为什么要这样?可以不这样吗?
  3. 有没有更好的方式?

当然,不是所有问题都能适用灵魂三问,但它适用大多数情况。

举个例子:看过性能优化相关文章的同学应该知道有这么一条规则,要减少页面上的 HTTP 请求。

这是什么?

先了解一下 HTTP 请求是啥,查资料发现原来是向服务器请求资源用的。

为什么要减少 HTTP 请求?

查资料发现:HTTP 请求需要经历 DNS 查找,TCP 握手,SSL 握手(如果有的话)等一系列过程,才能真正发出这个请求。并且现代浏览器对于 TCP 并发数也是有限制的,超过 TCP 并发数的 HTTP 请求只能等前面的请求完成了才能继续发送。

我们可以打开 chrome 开发者工具看一下一个 HTTP 请求所花费的具体时间。

在这里插入图片描述

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

名词解释:

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

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

有没有更好的方式?

使用 HTTP2,所有的请求都可以放在一个 TCP 连接上发送。HTTP2 还有好多东西要学,这里不深入讲解了。

经过灵魂三问后,是不是这条优化规则的来龙去脉全都理清了,并且在你查资料动手的过程中,知识会理解得更加深刻。

掌握了这种学习方法,并且时刻运用在学习中、工作中,突破瓶颈只是时间的问题

总结

下面提前回答一下可能会有的问题。

百度前端技术学院

百度前端技术学院 2017 年及往后的任务,如果没有报名,那就只能做部分任务。2016 年的任务则由于百度服务器的问题,很多题的示例图都裂了。这个其实是有解决方案的,那就是看别人的答案。把别人的源码下载下来,用浏览器打开 html 文件当示例图看。这两年的任务我都做了大部分,附上答案:

  1. 百度前端技术学院2016任务
  2. 百度前端技术学院2017任务

学历提升

我从 18 年开始,已经报考了成人高考大专,19 年报了自考本科。大专明年 1 月就能毕业,自考本科比较难,可能 2021 年或 2022 年才能考下来。

写在最后

从转行到现在,已经过去 3 年多了。不得不说转行当程序员给了我人生第二次机会,我也很喜欢这个职业。不过这几年一直都是在小公司,导致自己的技术和视野得不到很大的提升。所以现在的目标除了学习计算机专业外,就是进大厂,希望有一天能实现。

虽然今年已经 32 了,但我对未来仍然充满希望。努力地学习,努力地提升自己,为了成为一名顶尖的程序员而努力。

查看原文

赞 62 收藏 28 评论 18

蒋鹏飞 发布了文章 · 1月5日

工作都是公司的,技术才是自己的!| 底层技术人的2020年度总结

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。

昨日听闻某公司98年姑娘的噩耗,再结合自己的经历,深感:工作都是公司的,健康和技术这些才是自己的!拼命为资本打工,到头来不过是肥了资本家,苦了自己!愿天堂没有资本家的压榨!

最近平台在征文,写年度总结,我也心里痒痒。但是看了各位大佬的总结:

  1. 粉丝数万
  2. 阅读量上百万
  3. GitHub几千star
  4. 副业月入上万
  5. 炒股还能再赚几十万

一度吓得我不敢动笔,因为这些我都没有!!我只是一个被焦虑驱赶着的底层技术人😭

我的2020是从糟糕和愤怒中开始的,一切都源于一次绩效谈话:

领导:“Dennis,今年你的绩效非常好,涨薪是xxx”

我:“哦。。。” (没有升职,涨薪不过是公司平均数,虽然对这个结果有过预期,但是心里还是很难受)

领导:“你有什么想问或者想说的吗?”

我:“没有。。。” (说什么呢,你这只是通知我结果,又不是跟我商量,我说啥有用吗,还不如不说,难不成骂你一顿?)

领导:“嗯。。。Dennis。。。大家虽然都知道你技术很厉害,但是都是你周围的同事知道,有时候跟上面领导搞好关系也是很重要的!”

我:“哦。。。”(你是也觉得这个对我不公平吗,所以补这么一句,我没问原因,我也不想知道原因,你还补这么一句,是觉得准备了一堆PUA的话语没了用武之地吗?这是叫我接下来要注意跪舔领导,好升职加薪吗?)

这次谈话全程我没说几个字,但是心里很愤怒!我是2017年5月加入这家公司的,之前是在上海,因为结婚了,想着回成都安家就到成都找工作了。找的时候很随意,总共面试了半个月吧,拿了几个offer,包括某二线大厂,经过比较,觉得外企不加班,工作环境也还可以,薪资也能接受,就来了这里。

当时进来的时候是个新项目组,除了上面提到的领导,我是成都第一个前端,后面又加入了一些小伙伴,最多的时候,我们组前端有10个。我们这个项目组从一开始成立就是公司的开荒团队,基本都是做新项目,上面老板有了什么新想法都会扔给我们实现。使用的技术栈我们也没有决定权,基本都是美国架构团队决定的,所以第一个项目用的是Ember,估计很多小伙伴都没听说过,后面几个项目才引入了React,还用React-Native做过APP。因为东西基本是新的,我入职最早,而且技术上也有点底子,学东西也快,所以可以不要脸的说,后面加入的小伙伴或多或少我都带过,以致于坐我旁边的小伙伴说:“Dennis,我来这里两年多,感觉没学到什么东西,唯独从你身上学了不少!”

2017年这半年项目有点乱糟糟的,美国架构团队主导,还跟美国同事合作,有时差沟通不方便,效率很低,感觉没弄出什么名堂。2018年慢慢的我们有了些自主权,项目算是进入正轨了,这一年我也全身心投入工作,任务专挑难的,产出高,BUG还少。我们一个迭代三周,我经常是三周的活儿一周多就干完了,于是我又去领下个迭代的任务,最多的时候我完成的任务超前两个迭代😳还发生过其他小伙伴去领下个迭代任务的时候,发现被我领太多,他们没有了的情况。所以整个2018年谈绩效时,我非常好,从完成的任务量来看,我是组内第一,我自己估算了下,超第二名20% -- 30%,超最后一名可能有50% -- 80%,因为统计数据员工看不到,这个数字是我自己估算的。但是组内第一肯定是没跑的,领导也是确认的。

于是2019年初,谈2018绩效的时候,领导说:“Dennis,你今年的绩效非常好,明年再这样是可以升职的!”我当时听了还挺高兴,有点奔头了,嘿嘿~直到某天鼠标滑到了某同事的头像上,嗯?职位变了,升职了!就这么悄悄咪咪的升职了,别说庆祝了,连个官宣邮件都没有,等着组内小伙伴自己去寻宝?说实话心里有点难受,之前职级跟我一样的,我以为我能升职,但是却是别人,但是想到领导说的话,今年继续好好干,我明年也能升职,又有点释怀了,晚一年就晚一年吧。

结果2020年初谈2019绩效的时候就出现了开头那一幕,又没升职!虽然我绩效跟2018一样,也是非常好,但是有毛用!心里很愤怒,不解,当时也不知道PUA这词,现在看来遇到老PUA了!去年骗我继续努力卖命,今年再骗我去跪舔领导?

愤怒归愤怒,冷静后开始想出路:是我做错了吗?是的,我是有错,我太老实了,只知道埋头干活,除了直系领导,上面的领导我都不熟,甚至CEO跟CTO是谁我都不知道,只知道是个外国人,国籍我都不太清楚;是的,我是有错,不该太懦弱,上次没升职,心里不舒服没有说出来,让人觉得好欺负了,所以这次继续;是的,我是有错,太埋头于业务了,技术虽然能够满足业务需求,但是除了写点公司业务外,在其他技术方面没有进一步建树,以致于中途面某大厂,一败涂地。

我落后了

曾经将所有心思都放到公司上,总想着怎么快点,好点完成任务,技术没有深入,够用就行,但是当公司不认可你这种付出后,怎么办?我发现,我落后了!这几年我只出去面试过一次,是隔壁组领导离职后拉我去面的,是某一线大厂,一面过了,二面没过,二面问了些源码,我都不知道。光写业务去了,只会import,谁管他是怎么实现的。。。当时就感觉有点落后,但是好歹还有份工作,这个公司我绩效好,说不定很快就能混个技术经理当当,就没继续深究了。现在升职技术经理没戏了,这种落后的感觉让我焦虑了。再仔细看看,这几年都拿着公司的平均涨薪,而2017年我是降薪回成都的,结果就是我现在的薪资还没2017在上海的时候高,而当时留在上海的小伙伴已经是我两倍甚至三倍的工资了,我焦虑的头发都秃了。。。

破局

落后了就赶上来吧,怎么赶?我不知道!但是我必须得做点什么,做什么呢?跪舔领导,升职加薪?貌似也是条路,但是我不会,我也不想去学。学不来跪舔就学技术吧,毕竟业务做再多也是公司的,技术才是自己的,从头开始整理前端知识架构吧!我在网上找了些架构图谱,还买了些网课,也看了些别人写的知识架构。发现很多都是深度不够,有些前端知识架构一篇文章就写完了,我到现在已经写了几十篇,数十万字,都还没写完。。。很多网课目标客户也是一两年的新手前端,难道我看了这个出去告诉别人:“我学完了XX课程,会React,Node。。。”别闹,我2016年初开始转行做前端(之前还干了两年多测试),已经做了4年了,光这样,肯定达不到我的目标。

于是,我决定,我自己来写,将那些我曾经凌磨两可的知识深入嚼碎,自己写成文章。深度一定要够,怎么才算深度够,至少要比某些网课讲的深!有这么一件事,当时我在某网课看了节Promise课程,但是他只讲了用法,这对我没什么用,Promise我都用了几年了。那怎么比他写的深呢?自己实现一个Promise,并且要符合Promise/A+标准,我花了点时间,把这个实现了,并且发布到了思否:手写一个Promise/A+,完美通过官方872个测试用例。后来这个网课助教回访,问我对课程有没有什么建议,我说,Promise太浅了,用法我早就会了,至少也来个手写Promise。当时我并没有说我已经手写过了。。。后来他们又补了一节手写Promise的课程,内容我就没看了。还有件事,我发现很多课程讲Express.js的时候只讲用法,不讲源码,反而喜欢讲Koa.js的源码,于是我自己写了Express.js源码解析,也写了Koa.js源码解析,写完后才发现,Express.js源码比Koa.js复杂多了,怪不得大家都喜欢写Koa.js源码,而不写Express.js的。

之前面某大厂不是源码挂了吗,除了Express.js,我还去看了很多源码,我把看源码过程都写成了文章。React常用技术栈都写了:React FiberReduxReact-ReduxRedux-ThunkRedux-SagaReact-Router。当然还写了很多其他的源码解析和基础知识。我写文章的时候很多时候都是写给未来的自己看的,因为我学东西,经常有这样的感受,学的时候啥都看懂了,但是过了三五个月就忘了,还得重头再来学一次。为了让三五个月后的自己能快速看懂复习好,我写的文章尽量深入浅出,好理解,写的源码解析尽量层层递进,从简单入手,而不是一上来就是源码把人弄得晕头转向。不少朋友在评论时也提到这点了,说我的文章好理解,比喻恰当,由浅入深,层层递进,后面我会继续保持这个风格~

所以我这一年没有去抢活儿干了,完成本职工作就行了,多的时间都拿来补充我的知识架构,写文章了,我把这些文章都汇总了一个列表:前端进阶知识汇总

变数

绩效谈完,不久就发生了大家都知道的大事:新冠爆发了!刚开始时,主要在中国爆发,外国还没受太大影响,我们公司除了安排员工在家办公外也没啥变化。但是过了几个月就不一样了,大家都知道的,美国爆了!我们的主要客户都在北美,而且我们的主营业务是各种体育赛事的软件服务,那可是雪上加霜了,都这会儿了,谁还出来体育活动啊?最严重的一个季度我们营业额直接同比下降80%!裁员开始了,隔壁组项目上线第二天全部裁了。美国人太贵,美国办公室基本算关门了,之前帮我们技术选型Ember的那个架构师也走了,不知道是被裁的,还是主动离职的。但是我们组一个人没裁,估计是看我们是开荒队伍,公司对新项目还抱有一线希望,所以没动我们。

又过了几个月,公司业绩还没有起色,又有了碎言碎语,又要裁一波,于是我们组前端被裁了两个:开头找我谈绩效的领导和说从我身上学了东西的小伙伴!同时被裁的还有我领导的领导,以及我们组后端领导。这是什么操作?小兵留了一堆,领导全裁了!我们也不知道,我们也不敢问。但是打听到他们裁员补偿还不错,n+2,不到一年算一年,年终奖折算,年假也折算成钱,要知道我们年假起步就是15天,在职第三年20天,第五年25天,年假折算差不多又是个把月工资。这可把我们没被裁的馋坏了,反正升职无望,不如拿了大礼包换个地方东山再起。于是接下来的半年我们天天盼着被裁,但是这都2021年了还没实现。。。

收获

其实我开始写文章并没有期望什么收获,纯粹就是个人的知识总结。但是有不少小伙伴给我点赞,也有小伙伴开始关注我,刚刚还拿了思否2020年度“Top Writer”,这里感谢各位小伙伴的喜爱和思否平台的支持~

有些朋友看了我的文章后也会向我发出面试邀请,职位有大厂的前端工程师,也有小厂的前端负责人,但是我全部都拒绝了。不是我矫情,而是我还有件大事:我们的小美妞出生了!就是我女儿出生了,名字叫“静静”,来,静静,给大家笑一个:

image-20210102202538973

虽然对现在公司有很多怨念,但是工作时间那是100个满意。我在这里待了三年多,加班总共才一两天,有次周六加班还拿了双倍工资。平时基本是上午10点到公司,下午六点就走了,有时候有个事什么的,打个招呼四点多也可以走。而且年假还多,我2020年的年假是20天。另外家里如果有事走不开,打个招呼就可以在家办公。这么灵活的时间更好照顾家里,所以这一年我一个面试都没有参加。

所以总结下来,我的2020年主要收获就是:

  1. 小美妞一枚
  2. 原创“前端进阶知识”系列博文45篇
  3. 全网粉丝7000+
  4. 全网阅读量30W
  5. 博客项目在GitHub有近300 star,近100 fork
  6. 思否2020年度“Top Writer”
  7. 掘金4级“优秀作者”,还上了运营官的感谢列表
  8. 开源中国2020年度“优秀源创作者”

2021 flag

flag还是要有的,万一实现了呢:

  1. 完成“前端进阶知识”系列博文
  2. 再拿个"Top Writer"
  3. 认真运营公众号,争取粉丝突破5000,目前不到500
  4. 出一本书,实体的或者电子的都行
  5. 成为React Contributor
  6. 副业收入实现0的突破

最后感谢思否给的平台,祝思否越办越好~

也感谢各位伙伴的阅读,祝大家新的一年财运滚滚,心想事成~

另外随便关注下我的公众号呗,帮我实现2021的flag:进击的大前端,谢谢各位老铁~

查看原文

赞 30 收藏 7 评论 16

蒋鹏飞 关注了用户 · 1月4日

民工哥 @jishuroad

民工哥,10多年职场老司机的经验分享,坚持自学一路从技术小白成长为互联网企业信息技术部门的负责人。

我的新书:《Linux系统运维指南》

微信公众号:民工哥技术之路

民工哥:知乎专栏

欢迎关注,我们一同交流,相互学习,共同成长!!

关注 3579

蒋鹏飞 赞了文章 · 1月4日

SegmentFault 2020 年度 Top Writer发布,我看了下,我。。。

image.png

有一群活跃在 SegmentFault 思否社区的一群卓越的开发者,他们热衷于分享知识与经验,他们布道技术与未来,他们让众多开发者受益,他们叫「Top Writer」。

SegmentFault 思否根据社区用户行为大数据(如文章 & 问答发布数量、获得声望 & 点赞量等)综合分析,从「技术问答」和「专栏文章」两个维度进行了2020年度「Top Writer」的评选。

image.png

2020年就这样远去了,这么魔幻而又让我们难忘的一年,它注定会成为我们每个人人生当中不可磨灭的一年,因为,这一年的种种经历,种种困难,种种感动,种种突破,种种坚持,种种的超越自我。

没有一种坚持会被辜负!再见 2020

是的,你的每一种努力,第一种坚持,都不会被辜负,加油吧,努力的打工人!!!!

查看原文

赞 4 收藏 0 评论 10