Giter Site home page Giter Site logo

blog's People

Contributors

zijue avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

Forkers

xxv0 joket1999

blog's Issues

1.generator的概念及实现原理

generator的概念

generator函数是协程在ES6的实现,最大的特点是可以交出函数的执行权(即暂停执行),也可以在合适的时机从暂停处恢复函数执行。generator函数不同于普通函数,需要在函数名之前加*作区分,语法如下:

function* gen(x) {
    var y = yield x + 2;
    return y;
}

let it = gen(1);
console.log(it.next()); // { value: 2, done: false }
console.log(it.next()); // { value: undefined, done: true }

上面的代码中,调用generator函数,会返回一个内部指针it。这就是generator函数不同于普通函数的另一个地方,即执行它不会返回结果,而是返回的是指针对象。每次调用指针itnext方法,会返回一个对象,表示当前阶段的信息(value和done)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示generator函数是否执行完毕。

generator的数据交换和错误处理

generator函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制。

function* gen(x) {
    const y = yield x + 2;
    return y;
}

let it = gen(1);
console.log(it.next());  // { value: 3, done: false }
console.log(it.next('输入值')); // { value: '输入值', done: true }

上面的代码中,第一个next方法的value属性,返回表达式x + 2的值(3)。第二个next方法带有参数输入值,这个参数可以传入generator函数,作为上个阶段异步任务的返回结果,被函数体内的变量y接收。

generator函数内部还可以部署错误处理代码,捕获函数体外抛出的错误:

function* gen(x) {
    try {
        let y = yield x + 2;
        return y;
    } catch (e) {
        console.log(e);
    }
}

let it = gen(1);
console.log(it.next());
console.log(it.throw('出错了'));

generator在ES5中如何实现?(ES6新增)

为了搞清楚generator的原理,可以先看看Babel是怎么编译的。源代码如下:

function* read() {
    const a = yield 1;
    const b = yield 2;
    return b;
}

Babel编译之后代码如下:

"use strict";

var _marked = /*#__PURE__*/regeneratorRuntime.mark(gen);

function gen() {
    var a, b;
    return regeneratorRuntime.wrap(function gen$(_context) {
        while (1) {
            switch (_context.prev = _context.next) {
                case 0:
                    _context.next = 2;
                    return 1;

                case 2:
                    a = _context.sent;
                    _context.next = 5;
                    return 2;

                case 5:
                    b = _context.sent;
                    return _context.abrupt("return", b);

                case 7:
                case "end":
                    return _context.stop();
            }
        }
    }, _marked);
}

上面的babel编译代码中,主要是依靠是switch...case...,通过regeneratorRuntime中提供的wrapmark方法实现generator功能,其中mark方法只起一个标记功能,没有什么实际功能。下面我们来完成这两个方法:

wrap函数返回了一个指针对象,该指针对象是可以调用next方法的,next方法可以接收值,同时返回包含valuedone属性的对象。所以我们先搭建一个框架:

const regeneratorRuntime = {
    mark(genFn) {
        return genFn
    },
    wrap(iterFn) { // iterFn指的是gen$函数,表示babel编译之后的generator函数
        let it = {};
        it.next = function (val) {
            return {
                value: xxx,
                done: yyy
            }
        }
        return it;
    }
}

我们再看gen$函数,它接收一个_context上下文对象,拥有next属性,通过next属性的值及switch...case走到相应的逻辑去执行,将switch...case中的返回结果作为it.next函数返回对象value属性的值,同时指针后移(改变_context.next的值)。完善一下代码:

const regeneratorRuntime = {
    mark(genFn) {
        return genFn
    },
    wrap(iterFn) {
        let ctx = {
            next: 0,
            done: false, // 表示迭代器没有执行完毕
            sent: null, // 用于接收用户传递的值
        }
        let it = {};
        it.next = function (val) {
            ctx.sent = val;
            let value = iterFn(ctx);
            return {
                value: value,
                done: ctx.done
            }
        }
        return it;
    }
}

最后,当代码迭代执行完成时,将上下文的状态done改为true。代码如下:

const regeneratorRuntime = {
    mark(genFn) {
        return genFn
    },
    wrap(iterFn) {
        let ctx = {
            next: 0,
            done: false, // 表示迭代器没有执行完毕
            stop() {
                ctx.done = true;
            },
            sent: null, // 用于接收用户传递的值
            abrupt(next, val) {
                ctx.next = next;
                return val
            }
        }
        let it = {};
        it.next = function (val) {
            ctx.sent = val;
            let value = iterFn(ctx);
            return {
                value: value,
                done: ctx.done
            }
        }
        return it;
    }
}

5.npm 的使用

文件模块解析流程

文件目录树如下所示:

.
├── test.js
├── zijue
│   └── index.js
└── zijue.js
// zijue.js
module.exports = 'zijue file'

// zijue/index.js
module.exports = 'zijue package index'

// test.js
const r = require('./zijue');
console.log(r);  // > zijue file

默认会先找文件(如 .js .json),找不到就会找对应的文件夹(zijue 文件夹)下的 index.js 文件。

删除 zijue.js 文件,并在 zijue 文件夹中添加 a.jspackage.json (表示包的描述信息)。文件结构如下:

.
├── test.js
└── zijue
    ├── a.js
    ├── index.js
    └── package.json
// zijue/package.json 
{
    "main": "a.js"
}

// zijue/a.js
module.exports = 'zijue package a'

// test.js
const r = require('./zijue');
console.log(r);  // > zijue package a

默认应该寻找 zijue/index.js 文件,当添加了 zijue/package.json 文件中的 main 属性之后,优先去查找了 a.js 文件。因为 main 的优先级更高

如果引入的模块没有 ./ 或者 ../ 或者绝对路径,会认为此模块是一个第三方模块或者核心模块

第三方模块

  • 第三方模块区分为全局本地
  • 本地代码中第三方模块都会安装到 node_modules 文件夹中
  • 第三方模块查找会根据 module.paths 不停的向上查找,直到找到为止
// module.paths
[
  '/Users/chisinong/Documents/zf/learn_code/my_lesson/node_modules',
  '/Users/chisinong/Documents/zf/learn_code/node_modules',
  '/Users/chisinong/Documents/zf/node_modules',
  '/Users/chisinong/Documents/node_modules',
  '/Users/chisinong/node_modules',
  '/Users/node_modules',
  '/node_modules'
]

全局第三方模块

全局第三方模块就是安装的时候带参数 -g;且全局安装的第三方模块可以在命令行中使用

我们以 nrm 模块为例演示(nrm 模块可以帮助我们快速且方便的切换 node 的源)

  • 全局安装 nrm 模块
$ npm install -g nrm
  • nrm 模块的简单使用
// 查看 node 源
$ nrm ls

* npm -----  https://registry.npmjs.org/
  yarn ----- https://registry.yarnpkg.com
  cnpm ----  http://r.cnpmjs.org/
  taobao --  https://registry.npm.taobao.org/
  nj ------  https://registry.nodejitsu.com/
  skimdb -- https://skimdb.npmjs.com/registry

// 切换 node 源
$ nrm use taobao

Registry has been set to: https://registry.npm.taobao.org/

全局安装的第三方模块为什么可以在命令行中使用?

  • 查看系统环境变量 $PATH
$ echo $PATH

/Users/chisinong/.nvm/versions/node/v12.14.1/bin:/Library/Frameworks/Python.framework/Versions/3.6/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/VMware Fusion.app/Contents/Public:/usr/local/go/bin:/Applications/Wireshark.app/Contents/MacOS

可以看到我使用的 node v12.14.1 的 bin 目录被添加到了系统环境 $PATH 中,那么 /Users/chisinong/.nvm/versions/node/v12.14.1/bin 下的所有可执行文件均可在命令行中执行

  • 查看 bin 目录下的内容
-rwxr-xr-x  1 chisinong  staff    40M  1  7  2020 node
lrwxr-xr-x  1 chisinong  staff    38B 10 19 16:59 npm -> ../lib/node_modules/npm/bin/npm-cli.js
lrwxr-xr-x  1 chisinong  staff    40B 10 19 16:41 npm-check -> ../lib/node_modules/npm-check/bin/cli.js
lrwxr-xr-x  1 chisinong  staff    46B 10 19 16:57 npm-upgrade -> ../lib/node_modules/npm-upgrade/lib/bin/cli.js
lrwxr-xr-x  1 chisinong  staff    38B 10 19 16:59 npx -> ../lib/node_modules/npm/bin/npx-cli.js
lrwxr-xr-x  1 chisinong  staff    30B 11 29 16:15 nrm -> ../lib/node_modules/nrm/cli.js

可以看到该目录下 nrm 软连接到具体的可执行文件中,相当于在命令行中执行 nrm 命令,其实是在执行 node_modules/nrm/cli.js 脚本

// cli.js 的行首
#!/usr/bin/env node

表明该文件是一个使用 node 环境的可执行脚本

  • 在命令行中使用 nrm 最终就是执行 /xx/node_modules/nrm/cli.js 路径下的 cli.js 脚本
命令行 nrm ->$PATH 中查找对应的可执行脚本 -> /Users/chisinong/.nvm/versions/node/v12.14.1/bin/nrm -> /Users/chisinong/.nvm/versions/node/v12.14.1/lib/node_modules/nrm/cli.js

如何发布自己的全局包

  • 本地模块初始化 npm init 生成 package.json 文件
$ npm init   

This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help init` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (my_lesson) 
version: (1.0.0) 
description: 练习项目
entry point: (zijue.js) 
test command: 
git repository: 
keywords: 
author: zijue
license: (ISC) MIT
About to write to /Users/chisinong/Documents/xx/learn_code/my_lesson/package.json:

{
  "name": "my_lesson",
  "version": "1.0.0",
  "description": "练习项目",
  "main": "zijue.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "zijue",
  "license": "MIT"
}


Is this OK? (yes) yes
// package.json
{
  "name": "my_lesson",
  "version": "1.0.0",
  "description": "练习项目",
  "main": "zijue.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "zijue",
  "license": "MIT"
}
  • package.json 中添加 bin 命令
{
  "name": "my_lesson",
  "version": "1.0.0",
  // 此配置在全局安装包时,命令行输入 zijue 命令对应会执行 ./bin/www 文件
  "bin": {
    "zijue": "./bin/www"
  },
  "description": "练习项目",
  "main": "zijue.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "zijue",
  "license": "MIT"
}

./bin/www 需要增加 #!/usr/bin/env node 可执行环境

  • 使用 npm link 命令暂时将本地代码连接到全局下,用于测试
$ npm link

npm WARN [email protected] No repository field.

up to date in 0.231s
/Users/chisinong/.nvm/versions/node/v12.14.1/bin/zijue -> /Users/chisinong/.nvm/versions/node/v12.14.1/lib/node_modules/my_lesson/bin/www
/Users/chisinong/.nvm/versions/node/v12.14.1/lib/node_modules/my_lesson -> /Users/chisinong/Documents/xx/learn_code/my_lesson
#!/usr/bin/env node

console.log('zijue');
$ zijue

zijue  // 通过 npm link 命令,可以在命令行中执行 zijue 命令,实际连接到 ./bin/www 可执行文件
  • 需要切换到官方 npm 源下
nrm use npm
  • 登录 npm 账户
// 有 npm 账户
npm login

// 无 npm 账户,可以通过 npmjs.com 网站注册,或下面命令
npm addUser
  • 发布包,发布前添加 .npmignore 文件剔除不需要发包的文件
npm publish  // 发包命令
npm install  // 包发布之后,就可以使用 npm 命令在任意位置安装
// 如果升级包,需要更新包的版本号,且 24 小时内不能重新发布

本地第三方模块

本地第三方模块表示在项目中使用,命令行中使用就用全局第三方模块。

如果像 webpack 这样的模块一般放项目依赖中使用,npm install webpack -D

  • --save-dev(-D) 表示该模块只在开发时使用
  • --save(-s 或者不写) 表示该模块开发上线时都需要

依赖分为:开发依赖、项目依赖、同版本依赖、捆绑依赖(打包依赖 npm pack)、可选依赖

版本问题:

  • ^^2.0.0 2 版本以上,3 版本以下
  • ~~1.2.0 1.2 版本及以上,但必须低于 1.3 版本

package.json 中的 scripts 字段

  ...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  ...

npm run 命令时会将当前文件夹 node_modules 下的 .bin 目录暂时添加到环境变量 PATH 中,所以可以运行 scripts 下定义的非全局的本地模块

npx 直接运行 node_modules/.bin 文件夹下的命令;若运行的命令没有,则较 npm run 多了一个下载功能,用完及删除

核心模块 不需要安装

15.cookie、session、jwt

cookie、session、jwt介绍

cookie、session、jwt都是为了解决http无状态下,服务器对客户端的身份识别设计的。它们的特点如下:

cookie

  • cookie是在http header中设置(不宜过大,设置过大可能会造成页面白屏);
  • cookie特点:可以通过浏览器添加cookie,服务端也可以设置cookie;每次请求都会携带cookie(每次请求都携带,比较浪费流量,所以需要合理设置);
  • cookie默认不能跨域(两个完全不同的域名),但是父子域名是可以设置的(子域名能拿到父域名中的数据);cookie存在前端里,所以不存在安全可言,cookie是可以被更改的;

session

  • session是基于cookie的;
  • session存储在服务器中,默认浏览器是拿不到的;session可以存放数据原则上没有上线,而且安全;
  • session默认都是存在内存中(如果服务器宕掉了,session就丢失了),所以现在基本都用redis数据库存储session;

jwt

  • jwt方案:服务器根据用户提供的信息生成一个令牌。浏览器每次请求都会带上令牌和信息,服务器会用提供的信息再次生成令牌做对比(里面不能存放隐私)。

cookie

我们使用koa创建服务器并设置cookie,代码如下:

const Koa = require('koa');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

router.get('/write', async function (ctx) {
    ctx.res.setHeader('Set-Cookie', ['name=zijue', 'age=18']);
    ctx.body = 'ok'
})

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

启动服务器后,在浏览器中请求:127.0.0.1/writeF12查看cookie:

如上图所以,我们成功设置了cookie。其中domain字段可以设置子域名获取父域名的cookie。例如:

  1. 修改主机/etc/hosts文件,添加两条127.0.0.1的自定义域名映射
127.0.0.1       a.test.zijue.cn
127.0.0.1       b.test.zijue.cn
  1. 对某个cookie键值对设置domain
const Koa = require('koa');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

router.get('/write', async function (ctx) {
    ctx.res.setHeader('Set-Cookie', ['name=zijue', 'age=18; domain=.test.zijue.cn']);
    ctx.body = 'ok'
});

router.get('/read', async function(ctx){
    ctx.body = ctx.req.headers['cookie'] || 'empty';
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

首先请求a.test.zijue.cn:3000/write路径设置cookie,可以看见age字段的domain值为.test.zijue.cn

接下来我们访问b.test.zijue.cn:3000/read,发现虽然a.test.zijue.cnb.test.zijue.cn域名不同,但是b.test.zijue.cn.test.zijue.cn的子域,所以可以拿到age字段的cookie。


cookie中httpOnly字段设置为true表示只能由浏览器获取,不能通过js脚本获得(document.cookie);


cookie中exipres/max-age字段表示cookie的存活时间,默认为session(浏览器关闭就销毁)。

将cookie操作封装成中间件(Koa自带,此处自定义理解原理)

const Koa = require('koa');
const Router = require('@koa/router');
const querystring = require('querystring');

const app = new Koa();
const router = new Router();

app.use(async (ctx, next) => {
    const cookies = [];
    ctx.myCookies = {
        set(key, value, options = {}) {
            let opts = [];
            if (options.domain) {
                opts.push(`domain=${options.domain}`)
            }
            if (options.httpOnly) {
                opts.push(`httpOnly=${options.httpOnly}`)
            }
            if (options.maxAge) {
                opts.push(`max-age=${options.maxAge}`)
            }

            cookies.push(`${key}=${value}; ${opts.join('; ')}`);
            ctx.res.setHeader('Set-Cookie', cookies)
        },
        get(key) {
            let cookieObj = querystring.parse(ctx.req.headers['cookie'], '; ');
            return cookieObj[key];
        }
    }
    return next();
})

router.get('/write', async function (ctx) {
    ctx.myCookies.set('name', 'zijue', {
        domain: '.test.zijue.cn',
        httpOnly: true
    });
    ctx.myCookies.set('age', '18');

    ctx.body = 'ok'
});

router.get('/read', async function (ctx) {
    ctx.body = ctx.myCookies.get('name');
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

我们知道cookie是可以改的,那么就存在很大的安全风险。所以为了校验cookie中的数据是否被修改,我们会采用加盐的方式校验。思路就是对需要校验的数据做加盐处理生成一个签名摘要并返回,等下一次请求来时进行验证,对比签名摘要确定数据是否修改,具体实现如下:

const Koa = require('koa');
const Router = require('@koa/router');
const querystring = require('querystring');
const crypto = require('crypto');

const app = new Koa();
const router = new Router();

const secret = 'zijue-secret'; // 秘钥,也就是加的盐

// 当浏览器请求时,会处理cookie中传递的值,将base64中的= + /忽略掉,所以我们传递前需要对这些值进行处理
const toBase64URL = (str) => {
    return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//, '_');
}

app.use(async (ctx, next) => {
    const cookies = [];
    ctx.myCookies = {
        set(key, value, options = {}) {
            let opts = [];
            if (options.domain) {
                opts.push(`domain=${options.domain}`)
            }
            if (options.httpOnly) {
                opts.push(`httpOnly=${options.httpOnly}`)
            }
            if (options.maxAge) {
                opts.push(`max-age=${options.maxAge}`)
            }
            if (options.signed) {
                let sign = crypto.createHmac('sha1', secret).update([key, value].join('=')).digest('base64');
                sign = toBase64URL(sign);
                cookies.push(`${key}-sign=${sign}`);
            }

            cookies.push(`${key}=${value}; ${opts.join('; ')}`);
            ctx.res.setHeader('Set-Cookie', cookies)
        },
        get(key, options = {}) {
            let cookieObj = querystring.parse(ctx.req.headers['cookie'], '; ');
            if (options.signed) {
                // 先获取上一次的签名
                let lastSign = cookieObj[`${key}-sign`];
                // 再次摘要传递的键值对获取新的签名
                let sign = toBase64URL(crypto.createHmac('sha1', secret).update([key, cookieObj[key]].join('=')).digest('base64'));
                if (sign == lastSign) {
                    return cookieObj[key];
                } else {
                    throw new Error('cookie被篡改')
                }
            }
            return cookieObj[key] || '';
        }
    }
    return next();
})

router.get('/write', async function (ctx) {
    ctx.myCookies.set('name', 'zijue', {
        domain: '.test.zijue.cn',
        httpOnly: true
    });
    ctx.myCookies.set('age', '12', { signed: true });

    ctx.body = 'ok'
});

router.get('/read', async function (ctx) {
    ctx.body = ctx.myCookies.get('age', { signed: true });
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

其中需要注意的是:浏览器请求时,会将传递的数据中base64= + /忽略(Base64URL 算法),所以我们需要进行处理。

session

虽然我们对cookie数据签名验证,但是依旧有可能被找到规律破解,同时大量敏感数据存储在前端也不太好,为了解决这个问题就需要用到session了。session可以类比于一家店,第一次请求店家会给你发放一个会员卡,之后每次请求操作都通过会员卡核实身份消费,这样就大大的提高了安全性。原理代码如下:

const Koa = require('koa');
const Router = require('@koa/router');
const uuid = require('uuid');

const app = new Koa();
const router = new Router();

let session = {}; //session可以理解为一个服务器记账的本子,为了稍后能通过这个本子找到具体信息

router.get('/consume', async function (ctx) {
    let hasVisit = ctx.cookies.get(cardName, { signed: true });
    if (hasVisit && session[hasVisit]) {
        session[hasVisit].mny -= 100;
        ctx.body = '恭喜你消费了 ' + session[hasVisit].mny
    } else {
        const id = uuid.v4();
        session[id] = { mny: 500 };
        ctx.cookies.set(cardName, id, { signed: true });
        ctx.body = '恭喜你已经是本店会员了 有500元'
    }
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000, function () {
    console.log('server start 3000');
});

Koa本身并没有实现session,需要通过安装第三方包实现。

jwt

使用session虽然安全性得到了保障,但是扩展性不行。当代码运行在服务器集群上时,session共享就是一个问题,而且因为session基于cookie,无法跨域,所以解决单点登录问题也很麻烦。为了解决这些问题,我们有一种更好的方式JWT。服务器不保存数据,所有数据都保存在客户端,每次请求都发回服务器。

  • jwt的原理
    JWT 的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样:
{
  "name": "zijue",
  "role": "admin",
  "expires": "xxxx-xx-xx"
}

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。

  • jwt的构成
    jwt由Header(头部)、Payload(负载)、Signature(签名)三部分通过.连接组成的字符串Header.Payload.Signature,实际样式如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
  1. Header
{
  "typ": "JWT",
  "alg": "HS256"
}

alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串;

  1. Payload
    Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段:
iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,如下:

{
  "name": "zijue",
  "role": "admin"
}

这个 JSON 对象也要使用 Base64URL 算法转成字符串。注意:base64是可以反解的,所以不要把敏感信息放到此处。

  1. Signature
    Signature 部分是对前两部分的签名,防止数据篡改。通过Header里面提供的签名算法(默认是 HMAC SHA256)和服务器指定的secret(保证只有服务器知道,不能泄露给用户),采用如下的计算公式得到签名:
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

搞清楚JWT的组成和原理后,我们自己来实现一下此逻辑:

const Koa = require('koa');
const Router = require('@koa/router');
const crypto = require('crypto');

const app = new Koa();
const router = new Router();
const secret = 'zijue-secret';

const jwt = { // 仅展示原理,部分逻辑写死
    header: {
        'typ': 'JWT',
        'alg': 'HS256',
    },
    toBase64Url(str) { // 将base64中的=、+、\替换成base64Url规定值
        return str.replace(/\=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
    },
    toBase64(content) {
        return this.toBase64Url(Buffer.from(JSON.stringify(content)).toString('base64'));
    },
    base64UrlUnescape(str) { // 将base64Url反解为base64
        /**
         * new Array(2) ==> [empty*3]
         * [empty*3].join('=') ==> '=='
         */
        str += new Array(5 - str.length % 4).join('='); // 末尾补=
        return str.replace(/-/g, '+').replace(/_/g, '/');
    },
    sign(content, secret) { // 将header、payload生成签名的方法
        return this.toBase64Url(crypto.createHmac('sha256', secret).update(content).digest('base64'));
    },
    encode(payload, secret) { // 生成token方法
        let header = this.toBase64(this.header);
        let content = this.toBase64(payload);
        let sign = this.sign([header, content].join('.'), secret);
        return [header, content, sign].join('.');
    },
    decode(token, secret) { // 解析token方法,未做过期时间校验
        let [header, payload, sign] = token.split('.');
        let newSign = this.sign([header, payload].join('.'), secret);
        if (newSign == sign) {
            return JSON.parse(Buffer.from(this.base64UrlUnescape(payload), 'base64').toString());
        } else {
            throw new Error('数据被篡改');
        }
    }
}

router.get('/login', async (ctx, next) => {
    let payload = {
        'id': '31914',
        'name': 'zijue',
        'exp': new Date(Date.now() + 15 * 60 * 1000).toUTCString() // 令牌过期时间
    }
    // 生成令牌token;数据不宜过大,一般情况下放id即可
    let token = jwt.encode(payload, secret);
    ctx.body = {
        err: 0,
        data: {
            payload,
            token
        }
    }
});

router.get('/validate', async (ctx, next) => {
    try {
        // jwt规范 Authorization: Bearer <token>;此处直接 Authorization: <token> 替代
        let token = ctx.get('Authorization'); // 将token放在请求头中,有效避免跨域的问题,是一种优雅的方式
        let payload = jwt.decode(token, secret);
        ctx.body = {
            err: 0,
            data: {
                payload
            }
        }
    } catch (e) {
        ctx.body = {
            err: 1
        }
    }
});

app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, function () {
    console.log('server start at 3000')
});

引用

13.手写koa核心原理

Koa介绍

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

Koa的使用

const Koa = require('koa');

const app = new Koa(); // const server = http.createServer(function (req, res) { })

app.use(ctx => {
    ctx.body = 'zijue'
});

app.listen(3000, function () {
    console.log('Koa server start at 3000');
});

运行上述代码,便可开启koa服务。在浏览器中访问http://127.0.0.1:3000/,页面便会显示zijue字样。

很明显,app就是一个http.createServer创建的一个实例;同时还可以绑定error事件,说明本身继承了EventEmitter

const Koa = require('koa');

const app = new Koa();

app.use(ctx => {
    throw Error('报错了');
    ctx.body = 'zijue'
});

app.listen(3000, function () {
    console.log('Koa server start at 3000');
});

app.on('error', function(err){
    console.log('err: ', err); // 打印程序执行中的错误
});

接下来看看app.use(ctx =>{ })中的ctx,首先看看下面这段代码:

app.use(ctx => {
    console.log(ctx.req.url); // http原生的
    console.log(ctx.request.req.url); // koa上封装的request上有req属性。这是为了在request对象中可以通过this获取到原生的req
    
    console.log(ctx.request.query); // koa封装的
    console.log(ctx.query); // koa中封装的request对象的属性被代理到了ctx对象上

    ctx.body = 'zijue'; // 最终会执行 res.end(ctx.body)

    console.log(ctx.response.res); // koa上封装的response上有res属性(http原生的)
    console.log(ctx.response.body); // 同样的,koa中封装的response对象的属性被代理到了ctx对象上
});

koa核心原理的实现

为了和koa源码保持一致,首先创建如下的目录结构:

koa
├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── package.json
  • 首先构建Koa类,让代码不具备其它功能先跑起来
// application.js

const EventEmitter = require('events');
const http = require('http');

class Koa extends EventEmitter {
    constructor() {
        super();
    }
    handleRequest(req, res) {
        console.log(req.url);
        res.end('zijue ~'); // 在浏览器中访问127.0.0.1:3000页面正常返回显示该内容
    }
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
module.exports = Koa;
  • 完成app.use功能

app.use传递的函数我们称之为中间件函数,它会接受Koa传递的上下文参数ctx对象,该对象上拥有requestresponse封装对象作为属性;

// application.js

const EventEmitter = require('events');
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa extends EventEmitter {
    constructor() {
        super();
        // 通过原型链的方式,保证应用之间的隔离;否则多个应用共享一个上下文,会造成混乱
        this.context = Object.create(context); // this.context.__proto__ = context
        this.request = Object.create(request);
        this.response = Object.create(response);
    }
    use(middleware) {
        this.middleware = middleware;
    }
    createContext(req, res) {
        // 处理应用间上下文需要隔离,一个应用下的多个请求之间也是需要隔离上下文的。保证每次请求对象和响应对象的独立
        const ctx = Object.create(this.context); // ctx.__proto__.__proto__ = context
        const request = Object.create(this.request);
        const response = Object.create(this.response);

        ctx.request = request; // request.xxx 都是封装的
        ctx.req = ctx.request.req = req; // req.xxx 就是原生的
        ctx.response = response;
        ctx.res = ctx.response.res = res;
        return ctx
    }
    handleRequest(req, res) {
        const ctx = this.createContext(req, res);
        this.middleware(ctx);
        if (ctx.body) {
            res.end(ctx.body);
        } else {
            res.end('Not Found');
        }
    }
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
module.exports = Koa;

引入我们写的Koa代码,测试一下:

const Koa = require('./koa');

const app = new Koa();

app.use(ctx => {
    ctx.body = 'hi, zijue'
    console.log(ctx.req.url);
})

app.listen(3000, function () {
    console.log('Koa server start at 3000');
});

在浏览器中访问,页面正常显示hi, zijue,控制台也打印了req.url

  • 扩展requestresponse对象
// request.js

const url = require('url');

request = {
    get url() { // Object.defineProperty 属性访问器
        return this.req.url
    },
    get path() {
        return url.parse(this.url).pathname;
    },
    get query() {
        return url.parse(this.url, true).query;
    }
}
module.exports = request;
// response.js

response = {
    _body: undefined,
    get body() {
        return this._body;
    },
    set body(value) {
        this._body = value;
    }
}
module.exports = response;
  • requestresponse对象上的方法和属性代理到context对象上
// context.js

const context = {}

function defineGetter(target, key) {
    context.__defineGetter__(key, function () {
        return this[target][key]
    })
}

function defineSetter(target, key) {
    context.__defineSetter__(key, function (val) {
        this[target][key] = val;
    })
}

// 此处按照koa源码使用的api编写,也可以使用defineProperty、proxy等方式
defineGetter('request', 'query');
defineGetter('request', 'path');

defineGetter('response', 'body');
defineSetter('response', 'body');

module.exports = context;
  • 多个中间件的组合处理
    先看两段代码的执行过程:
function sleep(time) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log('sleep');
            resolve();
        }, time);
    })
}

app.use(async (ctx, next) => {
    console.log('1');
    next();
    console.log('2');
    ctx.body = 'hi, zijue ~'
});

app.use(async (ctx, next) => {
    console.log('3');
    await sleep(1000);
    next();
    console.log('4');
});

app.use(async (ctx, next) => {
    console.log('5');
    next();
    console.log('6');
});

执行顺序为:1 -> 3 -> 2 -> 页面显示hi, zijue ~ -> (等待1s) sleep -> 5 -> 6 -> 4;

app.use(async (ctx, next) => {
    console.log('1');
    await next();
    console.log('2');
    ctx.body = 'hi, zijue ~'
});

app.use(async (ctx, next) => {
    console.log('3');
    await sleep(1000);
    await next();
    console.log('4');
});

app.use(async (ctx, next) => {
    console.log('5');
    await next();
    console.log('6');
});

执行顺序为:1 -> 3 -> (等待1s) sleep -> 5 -> 6 -> 4 -> 2 -> 页面显示hi, zijue ~

通过结果可以看出当第一个中间件(从上至下的顺序)执行完毕后,请求就被响应了。所以在Koa中的中间件函数中,调用next()时,前面必须加awaitreturn,这样才能保证后面的中间件执行完成。

对于使用await的多个koa中间件,koa会将传入的多个中间件进行组合处理,内部会将这三个函数全部包装成promise,并且将这三个promise串联起来,内部会使用promise连接起来。当第一个use传入的中间件执行完,整个请求就完成了。

新增compose组合函数:

// application.js

const EventEmitter = require('events');
const http = require('http');
const context = require('./context');
const request = require('./request');
const response = require('./response');

class Koa extends EventEmitter {
    constructor() {
        super();
        // 通过原型链的方式,保证应用之间的隔离;否则多个应用共享一个上下文,会造成混乱
        this.context = Object.create(context); // this.context.__proto__ = context
        this.request = Object.create(request);
        this.response = Object.create(response);
        this.middlewares = [];
    }
    use(middleware) {
        this.middlewares.push(middleware);
    }
    compose(ctx) {
        let dispatch = (i) => {
            if (this.middlewares.length == i) return Promise.resolve(); // 当 执行下标 == 中间件长度,表示所有中间件执行完毕
            return Promise.resolve(this.middlewares[i](ctx, () => dispatch(i + 1))); // 否则,执行当前下标的中间件,并将下标后移的next函数传入中间件
        }
        return dispatch(0);
    }
    createContext(req, res) {
        // 处理应用间上下文需要隔离,一个应用下的多个请求之间也是需要隔离上下文的。保证每次请求对象和响应对象的独立
        const ctx = Object.create(this.context); // ctx.__proto__.__proto__ = context
        const request = Object.create(this.request);
        const response = Object.create(this.response);

        ctx.request = request; // request.xxx 都是封装的
        ctx.req = ctx.request.req = req; // req.xxx 就是原生的
        ctx.response = response;
        ctx.res = ctx.response.res = res;
        return ctx
    }
    handleRequest(req, res) {
        const ctx = this.createContext(req, res);
        res.statusCode = 404;
        this.compose(ctx).then(() => {
            if (ctx.body) {
                res.end(ctx.body);
            } else {
                res.end('Not Found');
            }
        }).catch(err => {
            this.emit('error', err);
        })
    }
    listen(...args) {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(...args);
    }
}
module.exports = Koa;

至此,Koa最核心的功能就写完了。但是还有个问题需要解决,就是当用户在中间件中调用两次next()时就会出问题,为此我们需要添加执行标识并限制,代码如下:

    compose(ctx) {
        // 将middlewares中的所有方法拿出来,先调用第一个,第一个完毕后,会调用next,再去调用执行第二个
        let index = -1; // 执行标识
        let dispatch = (i) => {
            if (i <= index) return Promise.reject('next() called multiple times.');
            index = i;
            if (this.middlewares.length == i) return Promise.resolve(); // 当 执行下标 == 中间件长度,表示所有中间件执行完毕
            return Promise.resolve(this.middlewares[i](ctx, () => dispatch(i + 1))); // 否则,执行当前下标的中间件,并将下标后移的next函数传入中间件
        }
        return dispatch(0);
    }

3.promise链式调用

通过例子看Promise链式调用

假设有这样一个需求,需要先读取a文件中的文件路径,再根据获取到的文件路径读取相应内容。目录结构如下:

.
├── a.txt     # file ctx: b.txt
├── b.txt     # file ctx: 紫珏
└── test.js    # 程序脚本

这个需求很简单,我们可以很快写出如下的回调代码:

const fs = require('fs');

fs.readFile('./a.txt', 'utf8', function (err, data) {
    fs.readFile(data, 'utf8', function (err, data) {
        console.log(data); // 紫珏
    })
})

这样的方式虽然简单,但是问题也很明显。如果嵌套多层就会陷入回调地狱,错误也不好处理,且代码都耦合在一起不利于维护。
针对这样的情况,我们可以使用Promise链式调用的方式,代码如下:

const fs = require('fs');

function readFile(...args) {
    return new Promise((resolve, reject) => {
        fs.readFile(...args, function (err, data) {
            if (err) return reject(err);
            resolve(data);
        })
    })
}

readFile('./a.txt', 'utf8').then(data => {
    return readFile(data, 'utf8'); // then方法返回一个Promise
}, err => {
    throw err
}).then(data => {
    console.log(data);
}, err => {
    console.log(err);
})

通过在then方法(成功和失败)中返回一个promisepromise会采用返回的promise的成功的值或失败的原因,传递到外层的下一次then方法中。

Promise链式调用原理实现

链式调用的特点:

  • then方法中,成功或失败的回调返回的是一个Promise,那么会采用返回的promise的状态,走外层下一次then中的成功或失败,同时将promise处理后的结果向下传递;
  • then方法中,成功或失败的回调返回的是一个普通值(非Promise),那么将返回的结果传递到外层下一次then的成功中去;
  • 如果在then方法中,成功或失败的回调执行时出错会走到外层下一个then的失败中去。

如果返回的是一个失败的promise或者报错了,才会走外层下一个then的失败,否则全部走成功。

如何实现链式调用?

可以先看看PromiseA+规范的描述,根据以下:

2.2.7 then must return a promise [3.3].
  promise2 = promise1.then(onFulfilled, onRejected);
    2.2.7.1 If either onFulfilled or onRejected returns a value x, run the Promise Resolution Procedure [[Resolve]](promise2, x).
    2.2.7.2 If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
    2.2.7.3 If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1.
    2.2.7.4 If onRejected is not a function and promise1 is rejected, promise2 must be rejected with the same reason as promise1.

通过阅读规范不难发现,每次调用then方法都需要返回一个全新的promise2,同时需要定义一个变量x接收onFulfilledonRejected的返回值,并将xpromise2传入Promise处理函数中;并且在onFulfilledonRejected执行出错时,将promise2的状态设为失败态同时将报错作为失败原因传递。
于是我们改进一下then方法,代码如下:

then(onFulfilled, onRejected) {
    let promise2 = new Promise((resolve, reject) => {
        if (this.status == FULFILLED) {
            try {
                let x = onFulfilled(this.value);
                resolve(x);
            } catch (e) {
                reject(e);
            }
        }
        if (this.status == REJECTED) {
            try {
                let x = onRejected(this.reason);
                resolve(x);
            } catch (e) {
                reject(e);
            }
        }
        if (this.status == PENDING) {
            this.onFulfilledCallbacks.push(() => {
                try {
                    let x = onFulfilled(this.value);
                    resolve(x);
                } catch (e) {
                    reject(e);
                }
            });
            this.onRejectedCallbacks.push(() => {
                try {
                    let x = onRejected(this.reason);
                    resolve(x);
                } catch (e) {
                    reject(e);
                }
            })
        }
    });
    return promise2;
}

在上述的代码中,我们将x直接作为promise2的成功结果进行返回只能适用于处理普通值,如果onFulfilledonRejected返回的是promise则需要进行处理,具体查看规范2.3

新建一个resolvePromise函数,用于处理x

function resolvePromise(x, promise2, resolve, reject) {
    // todo
}

替换then方法中的resolve(x)

then(onFulfilled, onRejected) {
    let promise2 = new Promise((resolve, reject) => {
        if (this.status == FULFILLED) {
            try {
                let x = onFulfilled(this.value);
                resolvePromise(x, promise2, resolve, reject);
            } catch (e) {
                reject(e);
            }
        }
    ...
}

但是上面代码有个问题,那就是new Promise()中的代码是立即执行的,那么resolvePromise(x, promise2, resolve, reject)中传入的promise2在初始化完成之前,会报错ReferenceError: Cannot access 'promise2' before initialization。解决办法在规范2.2.4中有说明:

2.2.4 onFulfilled or onRejected must not be called until the execution context stack contains only platform code. [3.1].
# onFulfilled 或 onRejected 不能在当前执行上下文中被调用,[3.1]为处理办法,我们这里使用平台通用的setTimeout来实现

[3.1] Here “platform code” means engine, environment, and promise implementation code. In practice, this requirement ensures that onFulfilled and onRejected execute asynchronously, after the event loop turn in which then is called, and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick. Since the promise implementation is considered platform code, it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.

修改then方法代码如下:

then(onFulfilled, onRejected) {
    let promise2 = new Promise((resolve, reject) => {
        if (this.status == FULFILLED) {
            setTimeout(() => {
                try {
                    let x = onFulfilled(this.value);
                    resolvePromise(x, promise2, resolve, reject);
                } catch (e) {
                    reject(e);
                }
            }, 0)
        }
        if (this.status == REJECTED) {
            setTimeout(() => {
                try {
                    let x = onRejected(this.reason);
                    resolvePromise(x, promise2, resolve, reject);
                } catch (e) {
                    reject(e);
                }
            })
        }
        if (this.status == PENDING) {
            this.onFulfilledCallbacks.push(() => {
                setTimeout(() => {
                    try {
                        let x = onFulfilled(this.value);
                        resolvePromise(x, promise2, resolve, reject);
                    } catch (e) {
                        reject(e);
                    }
                }, 0);
            });
            this.onRejectedCallbacks.push(() => {
                setTimeout(() => {
                    try {
                        let x = onRejected(this.reason);
                        resolvePromise(x, promise2, resolve, reject);
                    } catch (e) {
                        reject(e);
                    }
                }, 0);
            })
        }
    });
    return promise2;
}

至此,then方法部分的逻辑就完成了,接下来我们主要需要完善resolvePromise函数。

resolvePromise函数主要是为了处理xx的值有两种情况:1.普通值、2.Promise。一样,先看看规范2.3是怎么描述的:

2.3.3 Otherwise, if x is an object or function, 
    ...
# 如果x是一个对象或者是一个函数,会进入后续处理环节,暂时不看
2.3.4 If x is not an object or function, fulfill promise with x.
# 如果x不是一个对象或者函数,那么将x作为promise2成功的结果

那么可以获得一个resolvePromise最简洁的版本:

function resolvePromise(x, promise2, resolve, reject) {
    if ((typeof x === 'object' && x !== null) || (typeof x === 'function')) { // [2.2.3]
        // 只有x是对象或者函数时,才有可能是一个promise
        // todo
    } else { // 如果x是一个普通值,则直接调用resolve即可[2.3.4]
        resolve(x);
    }
}

接下来继续看规范2.2.3后续内容;

2.3.3.1 Let then be x.then. [3.5]
# 声明一个变量then,将x.then的值赋予then变量

2.3.3.2 If retrieving the property x.then results in a thrown exception e, reject promise with e as the reason.
# 如果尝试获取x.then属性时发生了错误,那么将错误作为promise2失败的原因

2.3.3.3 If then is a function, call it with x as this, first argument resolvePromise, and second argument rejectPromise, where:
    2.3.3.3.1 If/when resolvePromise is called with a value y, run [[Resolve]](promise, y).
    2.3.3.3.2 If/when rejectPromise is called with a reason r, reject promise with r.
# 如果变量then是一个函数,那么我就认为x是一个promise,并将x作为then的this调用;成功回调的结果叫做y,失败回调的结果叫做r;执行成功回调,有可能返回的y也是一个promise,所以需要再次解析,将y与promise2传入resolvePromise函数中继续解析,执行失败回调,直接将r作为失败的原因。

代码如下:

function resolvePromise(x, promise2, resolve, reject) {
    if ((typeof x === 'object' && x !== null) || (typeof x === 'function')) {
        try {
            let then = x.then; // 尝试获取then方法
            if (typeof then === 'function') { // 可以认为x是一个promise了
                then.call(x, (y) => {
                    resolvePromise(y, promise2, resolve, reject);
                }, (r) => {
                    reject(r);
                });
            } else {
                resolve(x);
            }
        } catch (e) {
            reject(e);
        }
    } else { // 如果x是一个普通值,则直接调用resolve即可
        resolve(x);
    }
}

上述代码中使用then.call(x, ...)的方式,是因为通过x.then(...)会再次去一次属性,触发get方法,而then.call(x, ...)不会。假如有人在get方法中设置第二次取值报错,就会导致代码执行出问题(一般不会有这种坑爹的代码,出现了直接掐死他)。

Object.defineProperty(x, 'then', {
    get() {
        if (times == 2) {
            throw new Error()
        }
    }
})

最后,resolvePromise还需要满足规范2.3.1规范2.3.3.3.3

2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
# 如果传入的x与promise是同一个对象,那么将类型错误作为错误原因返回

2.3.3.3.3 If both resolvePromise and rejectPromise are called, or multiple calls to the same argument are made, the first call takes precedence, and any further calls are ignored.
# 如果同时调用了resolve和reject,或者对同一个参数多次调用,那么只调用第一次的结果,其他的忽略

完善一下resolvePromise函数代码:

function resolvePromise(x, promise2, resolve, reject) {
    if (x === promise2) { // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
        return reject(new TypeError('循环引用'));
    }
    if ((typeof x === 'object' && x !== null) || (typeof x === 'function')) {
        let called = false; // 规范 2.3.3.3.3
        try {
            let then = x.then; // 尝试获取then方法
            if (typeof then === 'function') { // 可以认为x是一个promise了
                then.call(x, (y) => {
                    if (called) return;
                    called = true;
                    resolvePromise(y, promise2, resolve, reject);
                }, (r) => {
                    if (called) return;
                    called = true;
                    reject(r);
                });
            } else {
                resolve(x);
            }
        } catch (e) {
            if (called) return;
            called = true;
            reject(e);
        }
    } else { // 如果x是一个普通值,则直接调用resolve即可
        resolve(x);
    }
}

Promise核心原理部分代码的完善

Promise中then方法参数是可选的,例如:

new Promise((resolve, reject) => {
    reject('ok')
}).then().then().then((data) => {
    console.log(data);
}, err => {
    console.log(err, 'err')
})

reject的结果可以实现传递,原理其实很简单,就是不是函数就包装成函数,代码如下:

then(onFulfilled, onRejected) {
        onFulfilled = typeof onFulfilled == 'function' ? onFulfilled : v => v;
        onRejected = typeof onRejected == 'function' ? onRejected : e => { throw e };
        ...
}

至此,规范中的Promise全部功能基本实现了,完整代码如下:

const PENDING = 'PENDING'; // 默认等待态
const FULFILLED = 'FULFILLED'; // 成功态
const REJECTED = 'REJECTED'; // 失败态

function resolvePromise(x, promise2, resolve, reject) {
    if (x === promise2) { // 2.3.1 If promise and x refer to the same object, reject promise with a TypeError as the reason.
        return reject(new TypeError('循环引用'));
    }
    if ((typeof x === 'object' && x !== null) || (typeof x === 'function')) {
        let called = false; // 规范 2.3.3.3.3
        try {
            let then = x.then; // 尝试获取then方法
            if (typeof then === 'function') { // 可以认为x是一个promise了
                then.call(x, (y) => {
                    if (called) return;
                    called = true;
                    resolvePromise(y, promise2, resolve, reject);
                }, (r) => {
                    if (called) return;
                    called = true;
                    reject(r);
                });
            } else {
                resolve(x);
            }
        } catch (e) {
            if (called) return;
            called = true;
            reject(e);
        }
    } else { // 如果x是一个普通值,则直接调用resolve即可
        resolve(x);
    }
}

class Promise {
    constructor(executor) {
        this.status = PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];
        const resolve = (value) => {
            if (this.status == PENDING) {
                this.value = value;
                this.status = FULFILLED;
                this.onFulfilledCallbacks.forEach(fn => fn());
            }
        };
        const reject = (reason) => {
            if (this.status == PENDING) {
                this.reason = reason;
                this.status = REJECTED;
                this.onRejectedCallbacks.forEach(fn => fn());
            }
        };
        try {
            executor(resolve, reject); // 默认 new Promise 中的函数会立即执行
        } catch (e) { // 传入的函数执行出错,将错误传递给 reject,执行失败态的逻辑
            reject(e)
        }
    }
    then(onFulfilled, onRejected) {
        onFulfilled = typeof onFulfilled == 'function' ? onFulfilled : v => v;
        onRejected = typeof onRejected == 'function' ? onRejected : e => { throw e };
        let promise2 = new Promise((resolve, reject) => {
            if (this.status == FULFILLED) {
                setTimeout(() => {
                    try {
                        let x = onFulfilled(this.value);
                        resolvePromise(x, promise2, resolve, reject);
                    } catch (e) {
                        reject(e);
                    }
                }, 0)
            }
            if (this.status == REJECTED) {
                setTimeout(() => {
                    try {
                        let x = onRejected(this.reason);
                        resolvePromise(x, promise2, resolve, reject);
                    } catch (e) {
                        reject(e);
                    }
                })
            }
            if (this.status == PENDING) {
                this.onFulfilledCallbacks.push(() => {
                    setTimeout(() => {
                        try {
                            let x = onFulfilled(this.value);
                            resolvePromise(x, promise2, resolve, reject);
                        } catch (e) {
                            reject(e);
                        }
                    }, 0);
                });
                this.onRejectedCallbacks.push(() => {
                    setTimeout(() => {
                        try {
                            let x = onRejected(this.reason);
                            resolvePromise(x, promise2, resolve, reject);
                        } catch (e) {
                            reject(e);
                        }
                    }, 0);
                })
            }
        });
        return promise2;
    }
}

module.exports = Promise;

检验Promise核心原理代码

  • 首先需要在Promise类上添加一个静态方法:
Promise.deferred = function () {
    let dfd = {};
    dfd.promise = new Promise((resolve, reject) => {
        dfd.resolve = resolve;
        dfd.reject = reject;
    })
    return dfd
}
  • 全局安装promises-aplus-tests
npm install promises-aplus-tests -g
  • 执行测试:promises-aplus-tests promise/index.js
    如果没有变红报错,说明我们写的Promise代码没有问题。

Win10在PowerShell中使用curl的问题

问题

PowerShell中使用curl出现了如下问题,主要是我安装的win10没有IE浏览器

curl : 无法分析响应内容,因为 internet explorer 引擎不可用,或者 internet explorer 的首次启动配置不完整。请指定 usebasi cparsing 参数,然后再试一次。

解决办法

以管理员身份启动PowerShell,输入下面这段代码,回车:

Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Internet Explorer\Main" -Name "DisableFirstRunCustomize" -Value 2

参考

2.用同步的风格写异步逻辑及co库的实现原理

用generator读取关联文件的内容

假设现在有两个文件a.txtb.txt,内容如下:

.
├── a.txt  # b.txt
└── b.txt  # 紫珏

文件a存放着文件b的路径,文件b中存放着最终要读取的内容;现在我们希望用generator来完成此逻辑:

let fs = require('fs').promises;

function* read() {
    const a = yield fs.readFile('a.txt', 'utf8');
    const b = yield fs.readFile(a, 'utf8');
    return b;
}

最好就是向上面这段代码一样,像同步一样编写。变量a等待fs.readFile('a.txt', 'utf8')的结果,变量b等待fs.readFile(a, 'utf8')的结果并返回;但为了完成这样的功能,我们还需要编写调度器代码,很容易写出这样的代码:

let it = read();
let { value, done } = it.next();
value.then(data => {
    let { value, done } = it.next(data);
    value.then(data => {
        let { value, done } = it.next(data);
        console.log(value); // 紫珏
    })
});

不难看出,这样的代码写起来特别麻烦,而且出错也不好捕获。于是就有大佬写了一个co库用于解决这个问题,使用方法如下:

let fs = require('fs').promises;

function* read() { // 更像同步
    const a = yield fs.readFile('a.txt', 'utf8');
    const b = yield fs.readFile(a, 'utf8');
    return b;
}

const co = require('co');
co(read()).then(data => {
    console.log(data); // 紫珏
})

这样一来,代码就变得是否优雅与简洁。那么co库是如何实现的呢?能调用then方法,那么返回的一定是一个promise:

function co(it){
    return new Promise((resolve, reject)=>{
        // todo
    })
}

其次就是为了串行执行读取文件的逻辑,需要异步迭代。我们在同步代码中迭代用for,异步迭代则用递归

function co(it) {
    return new Promise((resolve, reject) => {
        function next(data) {
            let { value, done } = it.next(data);
            if (done) {
                return resolve(value);
            }
            Promise.resolve(value).then(data => {
                next(data);
            }, reject)
        }
        next();
    })
}

koaexpress都是基于这个co写法的。

1.环境搭建与配置

什么是 TypeScript

TypeScript 是 JavaScript 的超集,主要提供了类型系统对 ES6 的支持,它由 Microsoft 开发,代码开源在 github 上

  • TypeScript 更像后端 Java 语言,让 JS 可以开发大型企业应用
  • TS 提供的类型系统可以帮助我们在写代码时提供丰富的语法提示
  • 在编写代码时会对代码进行类型检查从而避免很多线上错误

环境配置

安装 TypeScript

全局安装 TypeScript 对 TS 进行编译

npm install -g typescript

安装完成后,我们就可以在任意位置执行 tsc 命令

tsc --init  # 生成 tsconfig.json
tsc helloworld.ts  # 将 .ts 文件编译成 js 文件

构建 TS 项目开发环境

在实际项目开发中,不可能每次都去调用 tsc 命令编译;故下面演示使用 rollup 配置项目开发环境

  • 创建项目并初始化
mkdir ts_project
cd ts_project
code .  # 使用 vscode 打开当前项目文件夹
npm init -y  # 初始化项目
  • 安装依赖
npm install rollup typescript rollup-plugin-typescript2 @rollup/plugin-node-resolve rollup-plugin-serve -D
  • 初始化 TS 配置文件
npx tsc --init
  • 配置 rollup

新建 rollup.config.js 并写入如下配置

import ts from 'rollup-plugin-typescript2';
import {nodeResolve} from '@rollup/plugin-node-resolve';
import serve from 'rollup-plugin-serve';
import path from 'path';

// rollup 支持 ES6 语法
export default {
    input: 'src/index.ts',
    output: {
        format: 'iife', // 立即执行  IIFE自执行函数
        file: path.resolve(__dirname, 'dist/bundle.js'), // 出口文件
        sourcemap: true, // 根据源码产生映射文件
    },
    plugins: [
        nodeResolve({  // 第三方文件解析
            extensions: ['.js', '.ts']
        }),
        ts({
            tsconfig: path.resolve(__dirname, 'tsconfig.json')
        }),
        serve({
            openPage: '/public/index.html',
            contentBase: '',
            port: 8080
        })
    ]
}
  • 配置 package.json
"scripts": {
    "dev": "rollup -c -w"
},
  • 创建 src/index.tspublic/index.html 文件,在 index.html 文件中引入 /dist/bundle.js
  • 通过命令 npm run dev 启动项目

8.vue3生命周期实现原理

生命周期的使用

        const state = reactive({ name: 'zijue' });
        const App = {
            setup() {
                onMounted(() => {
                    let instance = getCurrentInstance(); // vue3提供的api,用于在钩子函数里获取组件实例
                    console.log('挂载完成', instance);
                });
                onBeforeMount(() => {
                    console.log('挂载前');
                });
                onBeforeUpdate(() => {
                    console.log('更新前');
                });
                onUpdated(() => {
                    console.log('更新后');
                });
                return () => {
                    return h('h1', state.name);
                }
            }
        };
        setTimeout(() => {
            state.name = 'xiaochi';
        }, 1000);
        createApp(App).mount('#app');

        /*
            挂载前
            挂载完成 {uid: 0, vnode: {…}, type: {…}, props: null, attrs: {…}, …}
            更新前
            更新后
         */

生命周期实现原理

const enum LifeCycles {
    BEFORE_MOUNT = 'bm',
    MOUNTED = 'm',
    BEFORE_UPDATE = 'bu',
    UPDATED = 'u'
}
function injectHook(lifecycle, hook, target) { // target指向的肯定是生命周期指向的实例
    // 后续执行时,可能是渲染儿子,此时currentInstance已经变成了儿子的实例,但是target的指向却永远是正确的(闭包记住的)
    if (!target) return; // 生命周期只能在setup函数中使用,如果target没有值说明使用位置不正确,直接返回或提示错误
    const hooks = target[lifecycle] || (target[lifecycle] = []); // 从实例上获取生命周期函数,没有就创建
    const wrap = () => {
        setCurrentInstance(target);
        hook.call(target); // 在执行生命周期前,用正确的实例替换回去,保证instance的正确性。也是为了保证用户在生命周期函数中调用getCurrentInstance时的正确性
        setCurrentInstance(null);
    }
    hooks.push(wrap);
}
// vue3中所有生命周期函数(钩子)都在setup中使用,在执行setup前,将组件实例赋给了全局变量currentInstance,
// 然后通过函数闭包的方式,让钩子函数始终记住正确的组件实例。
// 如何理解?假设我们有父子组件 app --> xxx,那么子组件的钩子函数会先执行,那么当父组件的钩子函数执行时currentInstance已经变成了儿子的,
// 但是我们使用函数闭包,就可以在传入钩子时就将其与组件实例正确绑定。能做到这一点也是利用了JS单线程的特点。
function createHook(lifecycle) {
    return function (hook, target = currentInstance) { // 全局的当前实例
        injectHook(lifecycle, hook, target); // 利用函数的闭包特性
    }
}
export const onBeforeMount = createHook(LifeCycles.BEFORE_MOUNT);
export const onMounted = createHook(LifeCycles.MOUNTED);
export const onBeforeUpdate = createHook(LifeCycles.BEFORE_UPDATE);
export const onUpdated = createHook(LifeCycles.UPDATED);
export const getCurrentInstance = () => {
    return currentInstance;
}
export const setCurrentInstance = (instance) => {
    currentInstance = instance;
}

其中需要说明的就是currentInstance赋值问题:

// 全局对象 currentInstance
export let currentInstance; // 用于在组件调用setup函数时,可以在函数执行过程中拿到当前组件的实例
function setupStatefulComponent(instance) {
    let component = instance.type;
    let { setup } = component;
    if (setup) {
        let setupContext = createSetupContext(instance);

        currentInstance = instance; // 执行setup前,记录当前的实例
        let setupResult = setup(instance.props, setupContext);
        currentInstance = null; // setup执行后,清空当前的记录

        handleSetupResult(instance, setupResult);
    } else {
        ...
    }
}

Vue3中生命周期都需要在setup函数中使用,所以,我们在执行setup函数前,将组件实例挂在全局变量currentInstance上,等setup执行完后,将其清空。同时,生命周期执行时,利用函数闭包的特性,保存当前生命周期与组件实例之间的关系,之后全局变量currentInstance不管怎么变化,生命周期指向的组件实例始终是正确的。
还有就是生命周期中,用户可以通过getCurrentInstance函数获取其指向的组件实例,就需要在钩子函数执行前后,将正确的实例指向赋值给全局变量currentInstance

function injectHook(lifecycle, hook, target) { // target指向的肯定是生命周期指向的实例
    // 后续执行时,可能是渲染儿子,此时currentInstance已经变成了儿子的实例,但是target的指向却永远是正确的(闭包记住的)
    if (!target) return; // 生命周期只能在setup函数中使用,如果target没有值说明使用位置不正确,直接返回或提示错误
    const hooks = target[lifecycle] || (target[lifecycle] = []); // 将生命周期保存在实例上,没有就创建
    const wrap = () => {
        setCurrentInstance(target); // 在执行生命周期前,用正确的实例替换回去,保证instance的正确性。也是为了保证用户在生命周期函数中调用getCurrentInstance时的正确性
        hook.call(target);
        setCurrentInstance(null);
    }
    hooks.push(wrap);
}

生命周期的调用

    const setupRenderEffect = (instance, container) => {
        effect(() => { // 每次状态变化后,都会重新执行effect
            if (!instance.isMounted) {
                let { bm, m } = instance;
                if (bm) {
                    invokeArrayFns(bm);
                }

                // 组件渲染的内容就是subTree
                let subTree = instance.render.call(instance.proxy, instance.proxy); // 调用render,render需要获取数据
                // 将subTree赋值给instance.subTree,等数据更新后做diff算法用
                instance.subTree = subTree;
                patch(null, subTree, container); // 渲染子树;即render返回的是h函数创建的虚拟节点:h('div', {}, 'hi, zijue')
                instance.isMounted = true; // 挂载完成

                if (m) {
                    invokeArrayFns(m);
                }
            } else {
                let { bu, u } = instance;
                if (bu) {
                    invokeArrayFns(bu);
                }

                const prevTree = instance.subTree; // 获取数据没变时(初始化时组件的)subTree
                // 再次调用render,此时使用的是最新的数据渲染
                const nextTree = instance.render.call(instance.proxy, instance.proxy);
                instance.subTree = nextTree; // 将新的subTree赋给instance.subTree供后续更新使用
                // 执行diff算法
                patch(prevTree, nextTree, container);

                if (u) {
                    invokeArrayFns(u);
                }
            }
        }, {
            scheduler: queueJob
        })
    };

1.高阶函数、函数柯里化

Promise 的核心原理主要是使用高级函数与发布订阅模式,在搞清楚Promise原理前,需要先了解一下这部分的内容。

高级函数

  • 什么是高级函数?

一个函数作为另一个函数的参数或作为另一个函数的返回值,则“另一个函数”可称之为高级函数。

例如下面这段代码:

function coreFn(a, b, c) {
    // 实现了核心逻辑 
    console.log('core fn', a, b, c)
}

Function.prototype.before = function (beforeFn) {
    return (...args) => {
        beforeFn();
        this(...args); // this => coreFn
    }
}

let newFn = coreFn.before(() => {
    console.log('before fn')
});

newFn(1, 2, 3);

/** 代码执行结果
 * before fn
 * core fn 1 2 3
 */

上述代码中before函数就是一个高级函数,在不改动核心函数的情况下添加功能。
before函数相应的就有after函数,常用于等待多个异步回调完成后,执行某些操作,直接贴出代码如下:

const fs = require('fs');

function after(times, callback) {
    let data = {}
    return function finish(key, value) { // 函数声明所在的作用域和执行的作用域不是同一个此时就会产生闭包
        data[key] = value;
        if (Reflect.ownKeys(data).length == times) {
            callback(data);
        }
    }
}
let finish = after(2, (school) => { // 调用两次finish之后,调用cb;发布订阅模式
    console.log(school);
})
fs.readFile('./name.txt', 'utf8', function (err, data) {
    finish('name', data)
})
fs.readFile('./age.txt', 'utf8', function (err, data) {
    finish('age', data)
});

函数柯里化

  • 柯里化的概念

如果一个函数有多个参数,可以根据参数的个数转化成n个函数(柯里化一般认为参数是一个一个的传递的)。

  • 偏函数的概念

根据参数的个数分解成函数,每次调用函数的参数个数可以不是一个。

  • 柯里化的应用

假设我们想写一个传入值类型判断的函数,第一时间可能会这样写:

function isType(type, val) {
    return Object.prototype.toString.call(val) === `[object ${type}]`
}

console.log(isType('String', 123));
console.log(isType('Number', 456));

/** 代码执行结果
 * false
 * true
 */

可以发现,假如我们需要多次判断,则要多次手动输入类型,既繁琐又容易写错。那么有没有更加优雅的方式,答案就是函数柯里化。
看下面这段代码:

function isType(type) {
    return function (val) {
        return Object.prototype.toString.call(val) === `[object ${type}]`
    }
}

let isString = isType('String');

console.log(isString('123'));
console.log(isString(123));

/** 代码执行结果
 * true
 * false
 */

可以看出,柯里化就是一个闭包函数,暂存了参数并返回了类型验证函数,之后可以重复调用。

通用的柯里化函数

上面的例子中,为了柯里化改造,重写了isType函数。但是实际开发中,我们更希望在不改变原函数的基础下实现柯里化。
代码如下:

function curring(fn) {
    const inner = (args = []) => {
        /** 对比函数的参数个数与已传入的参数个数进行对比
         * 如果传入的参数个数 大于或等于 函数的参数个数 ==> 执行函数
         * 否则,返回包裹 inner 的箭头函数(递归调用)
         */
        return args.length >= fn.length ? fn(...args) : (...arr) => inner([...args, ...arr]);
    }
    return inner();
}

演示效果如下:

// 示例一
function isType(type, val) {
    return Object.prototype.toString.call(val) === `[object ${type}]`
}

let isString = curring(isType)('String');
let isNumber = curring(isType)('Number')
let isBoolean = curring(isType)('Boolean');
console.log(isString(123));
console.log(isNumber(456));
console.log(isBoolean(123));

/** 代码执行结果
 * false
 * true
 * false
 */

// 示例二
function sum(a, b, c, d) {
    return a + b + c + d;
}

let fn = curring(sum);
let fn1 = fn(1);
let fn2 = fn1(2, 3);
let result = fn2(4);
console.log(result);

/** 代码执行结果
 * 10
 */

chromium在headless与non-headless下默认字体不一致导致的问题

背景

最近在利用puppeteer处理项目截图导出的问题,发现在无头模式下,有部分元素的位置发生了偏移,与用户桌面显示不一致

解决记录

通过与公司图表工程师沟通,才知发生偏移的元素的位置计算规则是通过字体才进行处理。于是我修改了chrome的默认字体,bug重现,基本可以定位该问题是由于webFont加载完成之前,图表元素通过默认字体计算位置信息导致的元素偏移。

  • 测试代码
const puppeteer = require('puppeteer');

const url = 'http://info.cern.ch';
const selector = 'h1';

const getFontProperty = async (page) => {
  const font = await page.evaluate((selector) => {
    const title = document.querySelector(selector);
    return getComputedStyle(title).font;
  }, selector);
  return font;
}

const printFontProperty = async (headless) => {
  const browser = await puppeteer.launch({headless: headless});
  const page = await browser.newPage();
  await page.goto(url);
  console.log(await getFontProperty(page));
  await browser.close();
}

(async () => {
  await printFontProperty(true);
  await printFontProperty(false);
})();
  • 输出
700 32px "Times New Roman"    // headless = true
700 32px "Microsoft YaHei"    // headless = false
  • headlessnon-headless 模式下默认字体不一致
  • 解决办法:在字体加载完成之后,重绘页面
await page.evaluateHandle('document.fonts.ready');
await page.reload({ waitUntil: ["networkidle0", "domcontentloaded"] });

感谢

6.组件更新及diff算法

组件更新

当依赖的属性变化时,会重新执行effect函数,我们再次调用render方法生成新的虚拟DOM,进行diff操作。

    const setupRenderEffect = (instance, container) => {
        effect(() => { // 每次状态变化后,都会重新执行effect
            if (!instance.isMounted) {
                ...
            } else {
                const prevTree = instance.subTree; // 获取数据没变时(初始化时组件的)subTree
                // 再次调用render,此时使用的是最新的数据渲染
                const nextTree = instance.render.call(instance.proxy, instance.proxy);
                instance.subTree = nextTree; // 将新的subTree赋给instance.subTree供后续更新使用
                // 执行diff算法
                patch(prevTree, nextTree, container);
            }
        })
    };

元素更新流程

前后元素不一致

两个不同虚拟节点不需要进行比较,直接移除老节点,将新的虚拟节点渲染成真实DOM进行挂载即可。

        const { createApp, h, reactive, toRefs, ref } = VueRuntimeDOM;
        const App = {
            setup() { // setup中返回的是对象,那么这个对象会被用于渲染使用;如果返回的是函数,会作为render方法
                const state = reactive({ name: 'zijue', age: 18 });
                const flag = ref(true);
                setTimeout(() => {
                    flag.value = false;
                }, 1000);
                return { ...toRefs(state), flag }
            },
            render({ name, age, flag }) { // 前后元素不一致
                if (flag.value) {
                    return h('div', { style: { color: 'red' } }, `hi, ${name.value}`); // div
                } else {
                    return h('p', { style: { color: 'blue' } }, `hello, ${name.value}`); // p
                }
            }
        }
        createApp(App).mount('#app');

runtime-core/src/renderer.ts

    const isSameVnode = (n1, n2) => {
        return n1.type === n2.type && n1.key === n2.key;
    };
    const unmount = (vnode) => {
        hostRemove(vnode.el);
    };
    const patch = (n1, n2, container) => {
        // 判断n1、n2是否为同一个元素;通过type和key判断
        if (n1 && !isSameVnode(n1, n2)) { // 如果type和key不一样则直接删除老节点后渲染新节点
            unmount(n1);
            n1 = null; // 如果n1为空,则直接重新渲染n2
        }
        ...
    };

前后元素一致

前后虚拟节点一样,则复用DOM元素,并且更新属性和子节点。

        const { createApp, h, reactive, toRefs, ref } = VueRuntimeDOM;
        const App = {
            setup() {
                const state = reactive({ name: 'zijue', age: 18 });
                const flag = ref(true);
                setTimeout(() => {
                    flag.value = false;
                }, 1000);
                return { ...toRefs(state), flag }
            },
            render({ name, age, flag }) { // 前后元素一致
                if (flag.value) {
                    return h('div', { style: { color: 'red' } }, `hi, ${name.value}`);
                } else {
                    return h('div', { style: { color: 'blue' } }, `hello, ${name.value}`);
                }
            }
        }
        createApp(App).mount('#app');

runtime-core/src/renderer.ts

    const patchProps = (el, oldProps, newProps) => {
        // 两个循环,第一次循环遍历新属性更新到旧节点上,第二次循环遍历旧属性清空新属性中没有的项
        if (oldProps !== newProps) {
            for (let key in newProps) {
                const prev = oldProps[key];
                const next = newProps[key];
                if (prev !== next) {
                    hostPatchProp(el, key, prev, next);
                }
            };
            for (let key in oldProps) {
                if (!(key in newProps)) {
                    hostPatchProp(el, key, oldProps[key], null);
                }
            }
        }
    };
    const patchChildren = (n1, n2, container) => {
        ...
    };
    const patchElement = (n1, n2, container) => { // 走到这里说明前后两个元素能够复用
        let el = n2.el = n1.el; // 复用DOM

        const oldProps = n1.props || {};
        const newProps = n2.props || {};
        patchProps(el, oldProps, newProps); // 更新属性
        patchChildren(n1, n2, el); // 更新子节点;diff算法
    };
  1. 新子节点是文本,直接新的替换旧的

这其中又分为两种情况:老节点是文本和老节点是数组;如果老节点是数组,需要先删除老的子节点,否则直接替换;

            // 1.1.新旧子节点都是文本
            render({ name, age, flag }) {
                if (flag.value) {
                    return h('div', { style: { color: 'red' } }, `hi, ${name.value}`);
                } else {
                    return h('div', { style: { color: 'blue' } }, `hello, ${name.value}`);
                }
            }

            // 2.2.新子节点是文本,旧子节点是数组
            render({ name, age, flag }) {
                if (flag.value) {
                    return h('div', { style: { color: 'red' } }, [
                        h('li', 'A'),
                        h('li', 'B'),
                    ]);
                } else {
                    return h('div', { style: { color: 'blue' } }, `hello, ${name.value}`);
                }
            }
    const patchChildren = (n1, n2, container) => {
        const c1 = n1.children;
        const c2 = n2.children;
        const prevShapeFlag = n1.shapeFlag;
        const shapeFlag = n2.shapeFlag;
        // 1.新子节点是文本,直接新的替换旧的
        if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
            if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 旧子节点是数组,先删除
                unmountChildren(c1);
            }
            if (c1 !== c2) {
                hostSetElementText(container, c2); // 直接替换
            }
        } else { // 新子节点是数组
            ...
        }
    };
  1. 新子节点是数组,老子节点是文本

将老子节点的父节点清空,然后挂载所有新子节点;

    const patchChildren = (n1, n2, container) => {
        const c1 = n1.children;
        const c2 = n2.children;
        const prevShapeFlag = n1.shapeFlag;
        const shapeFlag = n2.shapeFlag;
        // 1.新子节点是文本,直接新的替换旧的
        if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
            if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 旧子节点是数组,先删除
                unmountChildren(c1);
            }
            if (c1 !== c2) {
                hostSetElementText(container, c2); // 直接替换
            }
        } else { // 新子节点是数组
            if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 3.新旧子节点都是数组
                ...
            } else { // 2.新子节点是数组,旧子节点是文本
                hostSetElementText(container, ''); // 先将父节点清空
                mountChildren(c2, container); // 然后挂载新的子节点
            }
        }
    };
  1. 新老子节点都是数组

对于双方都是数组的情况,我们首先需要了解两个对比的方法:

  • sync from start:从头开始一个个比,遇到不同的就停止;
  • sync from end:从尾开始一个个比,遇到不同就停止;
    const patchKeyedChildren = (c1, c2, container) => {
        // diff 算法
        let i = 0; // 新旧子节点默认都是从头开始比对
        let e1 = c1.length - 1; // 旧子节点最后一个元素的下标
        let e2 = c2.length - 1; // 新子节点最后一个元素的下标
        // sync from start  从头开始一个个比,遇到不同的就停止
        while (i <= e1 && i <= e2) {
            const n1 = c1[i];
            const n2 = c2[i];
            if (isSameVnode(n1, n2)) { // 是同一个元素,继续对比这两个子节点的属性和子节点
                patch(n1, n2, container);
            } else { // 不同直接跳出循环,然后从尾部对比
                break;
            }
            i++;
        }
        // sync from end    从尾开始一个个比,一样是遇到不同的就停止
        while (i <= e1 && i <= e2) {
            const n1 = c1[e1];
            const n2 = c2[e2];
            if (isSameVnode(n1, n2)) {
                patch(n1, n2, container);
            } else {
                break;
            }
            e1--;
            e2--;
        }
    };

根据上面两种方法比对新老子节点数组,有以下几种情况:

  1. common sequence + mount:同序列挂载;新节点在不破坏老节点顺序的基础上增加新的节点;

可以发现,不管是从头部插入还是从尾部添加新的元素,ie1的关系始终满足i > e1

        if (i > e1) { // common sequence + mount    老的少,新的多
            if (i <= e2) { // 表示有新增的部分
                // 如何判断是添加到尾部还是插入到前面?
                // 判断 e2 + 1 与 c2.length 的大小;如果向后追加 e2 + 1 > c2.length 肯定成立
                const nextPos = e2 + 1;
                const anchor = nextPos < c2.length ? c2[nextPos].el : null; // 获取插入位置节点
                while (i <= e2) {
                    patch(null, c2[i++], container, anchor);
                }
            }
        }
  1. common sequence + unmount:同序列卸载;新节点在不破坏老节点顺序的基础上减少节点;

同样可以发现,不管是在头部删除,还是尾部删除元素,ie2的关系始终满足i > e2

        if (i > e1) {
            ...
        } else if (i > e2) { // common sequence + unmount   老的多,新的少
            while (i <= e1) { // 表示有需要删除的部分
                unmount(c1[i++]);
            }
        } 
  1. unknown sequence:乱序比对,diff中的核心算法,采用了最长递增子序列算法;

如上图虚线框中所示,这种乱序的该如何对比呢?

1). 以新节点建立元素key与下标索引的映射表;

            // A B [C D E Q] F G
            // A B [E C D H] F G
            // i = 2, e1 = 5, e2 = 5
            const s1 = i;
            const s2 = i;
            // 1.根据新节点生成一个索引的映射表
            const keyToNewIndexMap = new Map();
            for (let i = s2; i <= e2; i++) {
                const nextChild = c2[i];
                keyToNewIndexMap.set(nextChild.key, i);
            }

2). 遍历老节点数组,删除新节点中没有的老节点,即Q节点;并更新新节点中存在的老节点的属性及子节点,同时标识出来且同新节点的下标联系起来。如何理解这句话,我们看图:

            // 2.有了映射表之后,需要知道哪些可以被patch,哪些不能,哪些需要删除
            // 2.1.计算有几个新节点需要被patch
            const toBePatched = e2 - s2 + 1; // 4
            // 2.2.创建一个与需要patch等长的数组,用0填充
            const newIndexToOldIndexMap = new Array(toBePatched).fill(0); // [0, 0, 0, 0]
            // 2.3.遍历老节点,删除新节点中没有的,更新能复用元素并标记已patch
            for (let i = s1; i <= e1; i++) {
                const prevChild = c1[i];
                const newIndex = keyToNewIndexMap.get(prevChild.key); // 获取老节点在新节点中的下标索引
                if (newIndex == undefined) { // 老节点中有,新节点中没有的,直接删除
                    unmount(prevChild);
                } else {
                    // 将patch过的新节点对应下标与该节点在老节点中的位置一一映射(构建新老索引的关系);
                    // 映射完成后,值为0表示该新节点未patch即老节点中没有
                    newIndexToOldIndexMap[newIndex - s2] = i + 1; // [5, 3, 4, 0]
                    patch(prevChild, c2[newIndex], container); // 更新相同元素的属性与子节点
                }
            }

3). 经过之前的两步,我们已经更新了新老节点中都有的元素,剩下的工作只需要移动和新增元素。那么如何移动效率才最高呢?Vue3中采用算法求解最长递增子序列处理这个问题。接下来我们看看其原理(不深究其算法),是如何做的?

假设,我们需要求解如上图所示数组的最长递增子序列,下面通过图示的方法展示算法的实现过程:

采用如上图所示的方式,从头到尾遍历完整个数组,最终我们会得到一个(贪婪模式下)递增序列的索引数组seq,与记录当一个节点索引位置的数组p。整个的变化过程如下图所示:

在得到了贪婪模式下的递增序列索引数组seq和记录上一个节点位置的p后,就可以通过倒序查找的方式,得到我们需要的最长递增子序列。原理就是在知晓最后一个人,且每个人都知道自己的前一个人是谁,那么整个队列的顺序就可以始终如一,不管在队列中随机添加人员,都不会影响整个队列的顺序。

4). 实现代码如下:

const getSeq = (arr) => {
    let len = arr.length;
    const result = [0]; // 用来存放最长递增子序列的索引
    const p = arr.slice(0); // 用来存索引,用于记录自己前一个节点的下标
    let resultLastIndex;

    for (let i = 0; i < len; i++) {
        const arrI = arr[i]; // 获取数组中的每一项,但是其中值为0是无意义,需要忽略
        if (arrI !== 0) {
            resultLastIndex = result[result.length - 1];
            // 索引数组中最后一个对应的数组的值与当前数组拿出来的值进行对比,
            // arr[resultLastIndex] < arrI,将当前的索引添加到索引数组result中
            if (arr[resultLastIndex] < arrI) {
                p[i] = resultLastIndex; // 在放入之前记住前一个的索引
                result.push(i);
                continue; // 如果是比最后一项大,后续逻辑就不用走了
            }
            // 二分查找,找到已存入索引数组中对应的值第一个大于当前数组下标对应的值的下标
            let start = 0;
            let end = result.length - 1;
            let middle;
            while (start < end) { // 最终start == end
                middle = ((start + end) / 2) | 0; // 向下取整
                if (arr[result[middle]] < arrI) {
                    start = middle + 1;
                } else {
                    end = middle;
                }
            }
            if (arrI < arr[result[start]]) {
                if (start > 0) {
                    p[i] = result[start - 1]; // 替换的时候记录我替换那个的前一个的索引
                }
                result[start] = i; // 直接用当前的索引替换到老的索引
            }
        }
    }
    // 从结果的最后一项开始,倒序查找回来
    len = result.length;
    let last = result[len - 1];
    while (len-- > 0) {
        result[len] = last;
        last = p[last]; // 通过最后一项倒序查找
    }
    return result;
};
console.log(getSeq([2, 3, 1, 5, 6, 8, 7, 9, 4]));

最后,通过求得的最长递增子序列完成新老子节点的更新:

            // 2.4.求解最长递增子序列
            // 这里是求解[5, 3, 4, 0]的最长递增子序列,即求解不需要移动的元素有哪些
            let increasingNewIndexSeq = getSeq(newIndexToOldIndexMap); // [1, 2]
            let j = increasingNewIndexSeq.length - 1; // 取出最后一个的索引
            for (let i = toBePatched - 1; i >= 0; i--) {
                let currentIndex = i + s2; // 获取h节点的位置
                let childNode = c2[currentIndex];
                let anchor = currentIndex + 1 < c2.length ? c2[currentIndex + 1].el : null;
                // 如果以前不存在这个节点就创造出来,再进行插入操作
                if (newIndexToOldIndexMap[i] == 0) {
                    patch(null, childNode, container, anchor);
                } else {
                    if (increasingNewIndexSeq[j] !== i) {
                        hostInsert(childNode.el, container, anchor); // 存在直接将节点进行插入操作
                        // dom操作是具有移动性的,用的是以前的元素,但是都做了一遍重新插入
                    } else {
                        j--;
                    }
                }
            }

Docker安装Nginx、MongoDB、Redis

安装Redis

  • 拉取镜像
docker pull redis:6.2
  • 启动容器
docker run -v $PWD/data/:/data/ --name redis-6.2 -p 6379:6379 -d redis:6.2 redis-server --appendonly yes
  • 用用户自己设置的配置文件启动
docker run -v $PWD/conf/:/usr/local/etc/redis/ -v $PWD/data/:/data/ --name redis-6.2 -p 6379:6379 -d redis:6.2 redis-server /usr/local/etc/redis/redis.conf --appendonly yes

安装Nginx

  • 拉取镜像
docker pull nginx:1.21
  • 启动容器
docker run -v $PWD/conf/nginx.conf:/etc/nginx/nginx.conf -v $PWD/conf/conf.d/default.conf:/etc/nginx/conf.d/default.conf --name nginx-1.21 -d -p 80:80 nginx:1.21
  • 配置nginx.conf,让docker反向代理Mac宿主机
    location / {
        # 反向代理到宿主机上的服务
        proxy_pass http://docker.for.mac.host.internal:8000;
    }

安装MongoDB

  • 拉取镜像
docker pull mongo:4.4
  • 启动容器
docker run -v $PWD/data:/data/db --name mongo-4.4 -d -p 27017:27017 mongo:4.4
  • 使用用户自己设置的配置文件启动
docker run -v $PWD/data:/data/db -v $PWD/conf:/etc/mongo --name mongo-4.4 -d -p 27017:27017 mongo:4.4 --config /etc/mongo/mongod.conf

4.node 模块化

为什么需要模块化

模块本质上是封装,把模块内可访问的和不可以访问的区分得清清楚楚。同时为了解决冲突,实现高内聚低耦合

node 的模块化机制

node 遵循了 CommonJS 的模块规范来隔离每个模块的作用域。与 ES6 模块规范对比

  • CommonJS 依赖于 node 的特性,可以按需依赖,无法实现 tree-shaking
  • ES6 模块只能静态依赖,可以实现 tree-shaking

CommonJS 规范

  • 每一个文件都是一个模块
  • 需要通过 module.exports 导出需要给别人使用的值
  • 通过 require 拿到需要的结果

CommonJS 规范中分三种模块

  • 内置模块和核心模块:node 中自带的不需要安装,引用的时候不需要添加路径
  • 第三方模块
  • 自定义模块、文件模块

下面介绍三个内置模块便于后面学习 CommonJS 规范实现原理

const fs = require('fs');

let r1 = fs.readFileSync('./1.js', 'utf8');  // 同步读取文件内容
let r2 = fs.existsSync('./1.js');  // 同步判断是否存在的方法
const path = require('path');

console.log(path.resolve(__dirname, '1.js')); // 解析出一个绝对路径,默认以 process.cwd() 解析
console.log(path.join(__dirname, 'a', 'b')); // join 和 resolve 可以互换使用,但是‘/’不能使用 resolve,会跑到根路径下
console.log(path.resolve(__dirname, 'a', '/', 'b')); // 输出结果为‘/b’
console.log(path.join(__dirname, 'a', '/', 'b')); // 输出结果为‘xx/xx/xx/a/b’

console.log(path.extname('a.min.js')); // .js 获取文件扩展名
console.log(path.relative('a/', 'a/b/1.js')); // b/1.js 相减取差异的部分
console.log(path.dirname('a/b.js')); // a 获取目录名
const vm = require('vm');

let a = 1;
vm.runInThisContext('console.log(a)'); // ReferenceError: a is not defined

a = 1; // 等同于 global.a = 1;
vm.runInThisContext('console.log(a)'); // 1

// vm 运行方式同 new Function,但是不需要把字符串包装成函数。Function 与 eval 不同的是,Function 创建的函数只能在全局作用域中运行

使用 vscode 调试 node 代码

点击 debugger 按钮先建 node 调试文件 launch.json

// launch.json

{
    // 使用 IntelliSense 了解相关属性。 
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "启动程序",
            "skipFiles": [
                "<node_internals>/**" // 该项表示跳过 node 源代码,需要查看源代码时应删除
            ],
            "program": "${workspaceFolder}/temp.js"
        }
    ]
}

CommonJS 规范实现原理

通过调试源码梳理 require 模块引入代码执行顺序如下

  1. require 是一个 Module 原型上的方法 Module.prototype.require
  2. Module._load 加载方法(模块加载)
  3. Module._resolveFilename(解析文件名变成绝对路径并且带有后缀)
  4. new Module 创建一个模块 (id, exports) require 方法获取到的是 module.exports 属性
  5. Module.prototype.load 进行模块的加载
  6. js 模块 json 模块 根据不同的文件扩展名使用不同的策略去进行模块的加载
  7. fs.readFileSync 读取文件的内容
  8. module._compile 将内容包裹进一个函数
const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];
  1. 让文件执行,用户会给 exports 赋值
  2. 最终获取到的是 module.exports 的结果

核心原理流程

  • 读取文件
  • 创建 exports 空对象,并传给用户
  • 用户赋值后返回

手写 require 模块原理实现

const path = require('path');
const fs = require('fs');
const vm = require('vm')

function Module(id){
    this.id = id;
    this.exports = {}
}
// 策略模式:根据不同的后缀,定义解析规则
Module._extensions = {
    '.js'(module){
        let script = fs.readFileSync(module.id, 'utf-8'); // 读取文件内容
        let code = `(function(exports, require, module, __filename, __dirname){
            ${script}
        })`;
        let func = vm.runInThisContext(code);
        let exports = module.exports;
        let thisVal = exports;
        let dirname = path.dirname(module.id);
        func.call(thisVal, exports, req, module, module.id, dirname);
    },
    '.json'(){}
}

Module._resolveFilename = function(id){
    let filepath = path.resolve(__dirname, id);
    // 查看该文件是否存在,如果不存在尝试添加后缀
    let isExists = fs.existsSync(filepath);
    if (isExists) return filepath; // 文件存在则直接返回
    let keys = Object.keys(Module._extensions)
    for (let i = 0; i < keys.length; i++) {
        let newFilepath = filepath +  keys[i];
        if(fs.existsSync(newFilepath)) return newFilepath
    }
    // 循环结束仍未返回文件路径,说明文件不存在
    throw new Error('模块文件不存在')
}

Module.prototype.load = function(){
    // 核心的加载方法,根据文件不同的后缀名进行加载
    let extname = path.extname(this.id);
    Module._extensions[extname](this);
}

Module._load = function(id){
    let filename = Module._resolveFilename(id); // 就是将用户的路径变成绝对路径
    let module = new Module(filename);
    module.load(); // 内部会读取文件,用户会给 exports 对象赋值
    return module.exports
}
// 自定义 require 方法
function req(id){ // 根据用户名加载模块
    return Module._load(id)
}

const r = req('./1');
console.log(r);  // > zijue
<--------------------------------->
// 1.js
module.exports = 'zijue' 

给模块导入加缓存

Module._cache = {}
Module._load = function(id){
    let filename = Module._resolveFilename(id); // 就是将用户的路径变成绝对路径
    if(Module._cache[filename]){
        return Module._cache[filename].exports // 如果有缓存直接将上次缓存的结果返回
    }
    let module = new Module(filename);
    Module._cache[filename] = module;
    module.load(); // 内部会读取文件,用户会给 exports 对象赋值
    console.log('no cache')
    return module.exports
}

添加 .json 导入方法

Module._extensions = {
    '.js'(module){
        ...
    },
    '.json'(module){
        let script = fs.readFileSync(module.id, 'utf-8'); // 读取文件内容
        module.exports = JSON.parse(script);  // 文件中没有 module.exports,直接手动赋值
    }
}

module.exports 与 exports 的关系

在源码 let exports = module.exports 中 module.exports 和 exports 指向同一个引用类型 {},故 exports = [newVal] 并不会改变 module.exports 的空间

// 1.js
exports = 'zijue'

// test.js
...
const r = req('./1');
console.log(r);  // > {}

// <------------------------>
exports.a = 'zijue'

// test.js
...
const r = req('./1');
console.log(r);  // > { a: 'zijue' }

// <------------------------>
// module.exports 和 exports 同时写
module.exports = 'zijue 1'
exports = 'zijue 2'

// test.js
...
const r = req('./1');
console.log(r);  // > zijue 1
// module.exports = exports = {} ==> module.exports = 'zijue 1' ==> exports = 'zijue 2'

由此引出另一个问题:所有相关模块中的 global 都是同一个,尽量不要使用 global,可能会污染全局变量;但是有些例外,比如:数据库连接属性 conn

ES6 中既可以使用 export default 也可以使用 exports;ES6中使用exports {a}方式导出的是一个变量a,当使用import {a}时取值。

3.async+await处理异步任务

async+await的使用

这次我们使用async+await的方式编写串联读文件内容的逻辑,代码如下:

let fs = require('fs').promises;

async function read() {
    try {
        const a = await fs.readFile('a.txt', 'utf8');
        const b = await fs.readFile(a, 'utf8');
        return b;
    } catch (e) { // 此处catch错误后,不继续抛出错误就走成功态
        console.log(e);
    }
}

read().then(data => {
    console.log('success', data); // success 紫珏
}, err => {
    console.log(err);
})

可以发现,async+await本质上还是一个promise,同时还可以在函数体内try...catch。这是怎么实现的呢?

我们依旧用babel编译看看:

"use strict";

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
    try {
        var info = gen[key](arg);
        var value = info.value;
    } catch (error) {
        reject(error);
        return;
    }
    if (info.done) {
        resolve(value);
    } else {
        Promise.resolve(value).then(_next, _throw);
    }
}

function _asyncToGenerator(fn) {
    return function () {
        var self = this, args = arguments;
        return new Promise(function (resolve, reject) {
            var gen = fn.apply(self, args);
            function _next(value) {
                asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
            }
            function _throw(err) {
                asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
            }
            _next(undefined);
        });
    };
}

var fs = require('fs').promises;

function read() {
    return _read.apply(this, arguments);
}

function _read() {
    _read = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
        var a, b;
        return regeneratorRuntime.wrap(function _callee$(_context) {
            while (1) {
                switch (_context.prev = _context.next) {
                    case 0:
                        _context.prev = 0;
                        _context.next = 3;
                        return fs.readFile('a.txt', 'utf8');

                    case 3:
                        a = _context.sent;
                        _context.next = 6;
                        return fs.readFile(a, 'utf8');

                    case 6:
                        b = _context.sent;
                        return _context.abrupt("return", b);

                    case 10:
                        _context.prev = 10;
                        _context.t0 = _context["catch"](0);
                        console.log(_context.t0);

                    case 13:
                    case "end":
                        return _context.stop();
                }
            }
        }, _callee, null, [[0, 10]]);
    }));
    return _read.apply(this, arguments);
}

通过观察代码不难发现,async+await编译出来的结果就是在generator的基础上又包裹了一层函数_asyncToGenerator,而此函数跟co库的核心原理基本一致:

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
    try {
        var info = gen[key](arg); // var info = it.next(value)
        var value = info.value; // info.value => yield的返回值
    } catch (error) {
        reject(error);
        return;
    }
    if (info.done) {
        resolve(value);
    } else {
        Promise.resolve(value).then(_next, _throw); // 报错就会执行_throw函数
        /**
        try {
            var info = gen[key](arg); // it.throw(err),这样async中可以try...catch
            var value = info.value;
        } catch (error) {
            reject(error);
            return;
        }
         */
    }
}

function _asyncToGenerator(fn) {
    return function () {
        var self = this, args = arguments;
        return new Promise(function (resolve, reject) {
            var gen = fn.apply(self, args);
            function _next(value) {
                asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
            }
            function _throw(err) {
                asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
            }
            _next(undefined);
        });
    };
}

小结:async+await => co + generatorasync+await就是语法糖,本质上就是co库和generator,写起来像同步方法,但是内部还是递归调用异步方法。

puppeteer无头模式截图webFont字体加载问题解决记录

环境

  • puppeteer: 13.5.1
  • chromium: 970485

问题描述

headless模式下,puppeteer截图WinLinux平台展现效果不同;通过debug发现,造成这个现象的罪魁祸首就是字体

  • Linux截图效果如下:
  • Win截图效果如下:
  • 区别
    可以很清晰的发现,最上面一行总额的显示不一致,通过F12发现此处字体为webFont(阿里巴巴普惠体)

解决过程

  • 起初怀疑是Linux服务器下,字体未加载导致的;于是添加以下代码查看字体加载情况
const session = await page.target().createCDPSession();
await session.send('DOM.enable');
await session.send('CSS.enable');
session.on('CSS.fontsUpdated', event => {
  console.log(event);
  // event will be received when browser updates fonts on the page due to webfont loading.
});
page.goto(urlWithWebFont);
  • 发现字体正常加载;又怀疑是字体库下载太慢,导致截图时目标字体还未下载完所致,通过延时截图操作,字体依旧没按照预期显示
  • 最后,在海量的搜索尝试之后解决;方法如下:
// 在args中添加'--font-render-hinting=none',headless chromium中默认为 full
// https://peter.sh/experiments/chromium-command-line-switches/
const browser = await puppeteer.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox', '--font-render-hinting=none']
});

感谢

6.events 模块

events 模块介绍

events 模块是 nodejs 中一个很重要的模块,可以称之为发布/订阅模式。解决了多状态异步操作的响应问题

官方示例

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('触发事件-1');
});
myEmitter.on('event', () => {
  console.log('触发事件-2');
});
myEmitter.emit('event');

手写 events 模块核心方法

为了更好的理解 events 模块,通过手写 onemitoffonce 实现原理彻底搞清楚

实现 on 与 emit 方法

首先新建 events.jsdemo.js 文件

// events.js

function EventEmitter() {
    this._events = {}; // 实例属性
}

// {'绑定事件': [fn1, fn2, fn3]}
EventEmitter.prototype.on = function (eventName, callback) {
    if (!this._events) this._events = {}; // 原型继承不会继承实例属性;检查子类实例是否有 _events 属性,没有新建
    if (!this._events[eventName]) {
        this._events[eventName] = [callback];
    } else {
        this._events[eventName].push(callback);
    }
}

EventEmitter.prototype.emit = function (eventName, ...args) {
    if (!this._events) this._events = {}; // 原型继承不会继承实例属性;检查子类实例是否有 _events 属性,没有新建
    if (this._events[eventName]) {
        this._events[eventName].forEach(fn => fn(...args));
    }
}

module.exports = EventEmitter;
// demo.js

const EventEmitter = require('./events'); // 自定义 events 模块,实现源码核心功能
const util = require('util');

function XiaoChi(){}

util.inherits(XiaoChi, EventEmitter);

let xiaochi = new XiaoChi();
xiaochi.on('小池的日常', (...args)=>{
    console.log('吃饭', ...args);
})
xiaochi.on('小池的日常', (...args)=>{
    console.log('睡觉', ...args);
})
xiaochi.on('小池的日常', (...args)=>{
    console.log('撸代码', ...args);
})

xiaochi.emit('小池的日常', '偶尔耍耍 b 站');

on 方法主要就是将注册的事件添加到绑定事件的数组中,触发 emit 方法则是遍历执行相应事件的数组里方法。需要注意的是原型继承不会继承实例属性

上述方法中使用 util. 方法实现原型继承,顺便扩展一下 nodejs 中原型继承的几种方法

// node 原型继承的几种方式
XiaoChi.prototype.__proto__ = EventEmitter.prototype;
XiaoChi.prototype = Object.create(EventEmitter.prototype); // ES5 提供的方法

util.inherits(XiaoChi, EventEmitter); // node 新版本提供
util.inherits 实现原理是 Object.setPrototypeOf(XiaoChi.prototype, EventEmitter.prototype); // ES6 提供的方法

class XiaoChi extends EventEmitter {}; // ES6 语法,nodejs 源码暂时使用的是 ES5 语法

实现 off 与 once 方法

// events.js

...
EventEmitter.prototype.off = function (eventName, callback) {
    if (!this._events) this._events = {}; // 原型继承不会继承实例属性;检查子类实例是否有 _events 属性,没有新建
    if (this._events[eventName]) {
        this._events[eventName] = this._events[eventName].filter(fn => fn !== callback); // 过滤掉要移除的回调函数
    }
}
...
// demo.js

...
xiaochi.on('小池的日常', (...args)=>{
    console.log('吃饭', ...args);
})
xiaochi.on('小池的日常', (...args)=>{
    console.log('睡觉', ...args);
})
xiaochi.on('小池的日常', (...args)=>{
    console.log('撸代码', ...args);
})
playGame = (...args)=>{
    console.log('打游戏', ...args);
}
xiaochi.on('小池的日常', playGame)

xiaochi.emit('小池的日常', '偶尔耍耍 b 站');
xiaochi.off('小池的日常', playGame); // 取消绑定事件的回调函数必须有名,匿名函数不可以取消
xiaochi.emit('小池的日常', '偶尔耍耍 b 站');

/* 输出的结果
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
打游戏 偶尔耍耍 b 站
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
*/

以上为 off 方法的实现原理

once 方法则是执行一次绑定事件的回调函数后,通过 off 方法从绑定事件回调函数数组中移除。实现原理如下:

// events.js

...
EventEmitter.prototype.once = function (eventName, callback) {
    // 通过 AOP 编程的方式实现先执行 on 方法再执行 off 方法
    const _once = (...args) => {
        callback(...args);
        this.off(eventName, _once);
    }
    this.on(eventName, _once);
}
...
// demo.js

...
xiaochi.on('小池的日常', (...args) => {
    console.log('吃饭', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('睡觉', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('撸代码', ...args);
})
playGame = (...args) => {
    console.log('打游戏', ...args);
}
xiaochi.once('小池的日常', playGame)

xiaochi.emit('小池的日常', '偶尔耍耍 b 站');
xiaochi.emit('小池的日常', '偶尔耍耍 b 站');

/* 输出的结果
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
打游戏 偶尔耍耍 b 站
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
*/

上述实现的 once 代码还有一点问题,通过代码来演示;在执行 emit 之前,执行 off 方法取消 once 的事件绑定

// demo.js

...
xiaochi.on('小池的日常', (...args) => {
    console.log('吃饭', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('睡觉', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('撸代码', ...args);
})
playGame = (...args) => {
    console.log('打游戏', ...args);
}
xiaochi.once('小池的日常', playGame)

xiaochi.off('小池的日常', playGame); // 在执行 emit 前取消 once 绑定的回调函数
xiaochi.emit('小池的日常', '偶尔耍耍 b 站');
xiaochi.emit('小池的日常', '偶尔耍耍 b 站');

/* 输出的结果
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
打游戏 偶尔耍耍 b 站
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
*/

从结果看,off 方法并没有生效,这是因为 once 绑定事件的回调函数外层套了一层函数,所以直接取消无法匹配。解决方法如下

// events.js

...
EventEmitter.prototype.off = function (eventName, callback) {
    if (!this._events) this._events = {}; // 原型继承不会继承实例属性;检查子类实例是否有 _events 属性,没有新建
    if (this._events[eventName]) {
        this._events[eventName] = this._events[eventName].filter(fn => fn !== callback && fn.listener !== callback); // 过滤掉要移除的回调函数
    }
}

EventEmitter.prototype.once = function (eventName, callback) {
    // 通过 AOP 编程的方式实现先执行 on 方法再执行 off 方法
    const _once = (...args) => {
        callback(...args);
        this.off(eventName, _once);
    }
    _once.listener = callback; // 在 _once 上添加一个监听属性指向 callback 函数
    this.on(eventName, _once);
}
...

// 再次执行 demo.js,发现 off 方法作用生效了
/* 输出的结果
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
吃饭 偶尔耍耍 b 站
睡觉 偶尔耍耍 b 站
撸代码 偶尔耍耍 b 站
*/

newListener 绑定事件原理

newListener 是 nodejs 内部实现的一个绑定事件,在使用 on 方法绑定其它事件时触发该绑定事件的回调函数

实现原理如下

// events.js

...
// {'绑定事件': [fn1, fn2, fn3]}
EventEmitter.prototype.on = function (eventName, callback) {
    if (!this._events) this._events = {}; // 原型继承不会继承实例属性;检查子类实例是否有 _events 属性,没有新建

    // newListener 实现原理
    if (eventName !== 'newListener'){ // 源码中实现就是当绑定事件时,先触发 newListener 事件的回调再将绑定事件添加到对应的数组中
        this.emit('newListener', eventName);
    }

    if (!this._events[eventName]) {
        this._events[eventName] = [callback];
    } else {
        this._events[eventName].push(callback);
    }
}
...
// demo.js

...
let xiaochi = new XiaoChi();
xiaochi.on('newListener', (eventName) => { // 每次绑定事件都会触发此函数(先出发 newListener 事件,再将绑定事件添加在回调数组中)
    // 只要绑定事件我就立即触发
    process.nextTick(() => { // 使用 process.nextTick() 异步方式实现事件绑定添加发生在触发之前(源码是 newListener 事件触发先于事件的绑定)
        xiaochi.emit(eventName, '偶尔逛逛 b 站');
    })
});
xiaochi.on('小池的日常', (...args) => {
    console.log('吃饭', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('睡觉', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('撸代码', ...args);
})
...

/* 输出的结果
吃饭 偶尔逛逛 b 站
睡觉 偶尔逛逛 b 站
撸代码 偶尔逛逛 b 站
吃饭 偶尔逛逛 b 站
睡觉 偶尔逛逛 b 站
撸代码 偶尔逛逛 b 站
吃饭 偶尔逛逛 b 站
睡觉 偶尔逛逛 b 站
撸代码 偶尔逛逛 b 站
*/

上面代码绑定事件都执行了三次(源码如此),如果想只执行一次,可以自己添加防抖代码

// demo.js

...
let pending = false;
let xiaochi = new XiaoChi();
xiaochi.on('newListener', (eventName) => { // 每次绑定事件都会触发此函数(先出发 newListener 事件,再将绑定事件添加在回调数组中)
    if (pending) return;
    pending = true;
    // 只要绑定事件我就立即触发
    process.nextTick(() => { // 使用 process.nextTick() 异步方式实现事件绑定添加发生在触发之前(源码是 newListener 事件触发先于事件的绑定)
        xiaochi.emit(eventName, '偶尔逛逛 b 站');
        pending = false;
    })
});
xiaochi.on('小池的日常', (...args) => {
    console.log('吃饭', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('睡觉', ...args);
})
xiaochi.on('小池的日常', (...args) => {
    console.log('撸代码', ...args);
})
...

/* 输出的结果
吃饭 偶尔逛逛 b 站
睡觉 偶尔逛逛 b 站
撸代码 偶尔逛逛 b 站
*/

上述代码地址:https://github.com/Zijue/ExerciseCodes/tree/master/node_demo/%E6%89%8B%E5%86%99%20events%20%E6%A8%A1%E5%9D%97

11.http概念

http的核心概念

基础学习参考以下两篇文章

HTTP学习参考
HTTP 协议入门 - 阮一峰

http总结与特例

http特点

  • http是不保存状态的协议,使用cookie来管理状态
  • 为了防止每次请求都会造成无谓的tcp连接建立和断开,所以采用keep-alive的方式保持连接
  • http请求采用管线化的方式(可以并发请求,例jscss资源并发请求;同一域名下最大并发请求数为6)

静态资源加载可以使用cdn的方式增加请求的并发数--域名分割技术

http缺点

  • 通信采用明文
  • 不验证通信方的身份
  • 无法验证内容的完整性(内容可能被篡改)

通过SSL(安全套阶层)建立安全通信线路 HTTPS (超文本传输安全协议)

option请求方法

  • options跨域用的(默认先访问一次预检请求,能访问再发送真正的请求)
  • 简单请求(不会发送options)和复杂请求(浏览器发起的)
    • 简单请求只有getpost,如果在这两个请求的基础上增加自定义的header会变成复杂请求
    • 其它请求都是复杂请求

URI、URL与URN的区别

  • uri统一资源标识符;标识一个独一无二的资源(某人的身份证号)
  • url统一资源定位符;用地址定位一个资源(某人的家庭住址 --> 通过位置定位资源)
  • urn统一资源命名符;用名称定位一个资源(某人的身份证号 --> 通过身份证号表示某人,不通过某人的位置所在)。即通过名称来标识资源,不依赖于位置,并且有可能减少失效链接个个数

举个例子:

寻找某个具体的人,如果通过家庭地址找(xx省xx市xx区 ... xx单元xx室),这就是url(通过地址定位资源);如果通过身份证号去找就是urn(不通过某人所在的位置,而是通过特定规则的名称标识资源)

3.node 事件环

什么是 node 事件环

事件环是 node 处理非阻塞 I/O 操作的机制。尽管 js 是单线程处理的,但是当可能的时候,它们会把操作转移到系统内核中去,目前大多数内核都是多线程的,它们可以在后台处理多种操作,当其中的一个操作完成的时候,内核通知 node 将合适的回调函数添加到轮询队列中等待时机执行。

node 事件环执行流程解析

node官网对 node 事件环的说明:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/

在解析 node 事件环之前,了解以下两点

  • 浏览器和 node 事件环在 node v11 版本及更高版本中,执行结果都一样,本质上不一样
  • node 中有多个宏任务队列

下面通过图例的方式以便于更好的理解事件环的执行流程

注意:每个框被称为事件环机制的一个阶段

如上图所示,event loop 总共有六个阶段,每个阶段都有一个 FIFO 队列来执行回调。当事件环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列清空或执行了最大回调数,当该队列清空或达到回调限制,事件环将进入到下一阶段。

阶段概述

  • timers 执行定时器 setTimeout()setInterval() 的回调函数
  • pending callbacks 执行延迟到下一个循环迭代的 I/O 回调
  • idle, prepare 仅系统内部使用
  • poll 检索新的 I/O 事件,执行与 I/O 相关的回调(主要存放的异步 I/O 操作)。node 中基本上所有的异步 api 的回调都会在这个阶段来处理。node 将在适当的时候在此阻塞
  • check 执行 setImmediate() 的回调函数
  • close callbacks 执行一些关闭的回调函数,如:socket.on('close', ...)

默认是从上到下依次执行代码,依次清空每个队列中的回调方法,每调用一个宏任务都会清空微任务队列。

主栈 => 检测定时器中有没有到达的定时回调,有就执行(每执行一个宏任务都会清空微任务队列)=> 进入 poll 阶段(I/O 操作),逐一清空 => 检测 check 阶段队列中是否有 setImmediate 的回调,如果则进入 check 阶段并清空队列,如果没有就会在 poll 阶段阻塞 => 不停的看定时器队列中是否有回调函数,如果有则进入 timers 阶段执行

看到一位大佬写的关于 node event loop 的 blog,借用其中一张描述 event loop 的图

setImmediate() 对比 setTimeout()

  • setImmediate() 设计为一旦在当前 poll 阶段完成, 就执行脚本
  • setTimeout() 在指定的时间阀值(单位 ms)过后运行脚本

执行计时器的顺序将根据调用它们的上下文而异

如果运行以下不在 I/O 周期(即主模块)内的脚本,则执行两个计时器的顺序是非确定性的,因为计时器会受进程性能的约束(会受到计算机上其它正在运行应用程序的影响)

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

但是,如果你把这两个函数放入一个 I/O 循环内调用,setImmediate 总是被优先调用

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

使用 setImmediate() 相对于 setTimeout() 的主要优势是,如果 setImmediate() 是在 I/O 周期内被调度的,那它将会在其中任何的定时器之前执行,跟这里存在多少个定时器无关

process.nextTick

process.nextTick 是 node 实现的异步 API 的一部分,从技术上讲它不是事件环的一部分。不管事件环在哪个阶段,它在当前操作完成后处理并清空 nextTickQueue,优先于 microTaskQueue (微任务队列)

let bar;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) { callback(); }

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall has completed, bar hasn't been assigned any value
  console.log('bar', bar); // undefined
});

bar = 1;
let bar;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

对比这两段代码,通过将回调置于 process.nextTick() 中,脚本仍具有运行完成的能力,同时允许在调用回调之前初始化所有的变量、函数等。它还具有不让事件循环继续的优点,适用于让事件循环继续之前,警告用户发生错误的情况

7.编码

不同进制的表示方法

下面均为十进制数255的在不同进制中表示法

进制 表示法
二进制 0b11111111
八进制 0o377
十六进制 0xff

Node.js 中对进制操作的一些方法

// 把任意进制转成 10 进制
console.log(parseInt('11111111', 2)); // 255
// 把任意进制转成任意进制
console.log((0xff).toString(2)); // 11111111

// | & << 针对二进制
// << 左位移表示平方
console.log(1 << 2); // 左移两位,相当于 1 * 2^2 = 4
// | 或,比较每个 bit,只要有一个 bit 是 1 就为 1
// 0110 | 1100 = 1110
console.log(0b0110 | 0b1100, 0b1110); // 14 14
// & 与,比较每个 bit,只要 bit 全是 1 才为 1
// 0110 & 1100 = 0100
console.log(0b0110 & 0b1100, 0b0100); // 4 4

ASCII 码

计算机内部,所有信息最终都是一个二进制值。每一个二进制位(bit)有01两种状态,因此八个二进制位就可以组合出256种状态,这被称为一个字节(byte)。也就是说,一个字节一共可以用来表示256种不同的状态,每一个状态对应一个符号,就是256个符号,从0000000011111111

ASCII 码一共规定了128个字符的编码,只占用了一个字节的后面7位,最前面的一位统一规定为0

UTF-8 与 Unicode 编码

ASCII 码是单字节编码,只能表示128个字符,明显无法满足汉字字符的编码需求,于是**就推出了自己的编码就是GB2312 ,同样的日本、韩国也有自己的编码。不同的编码文件相互传递时,如果不知道编码格式就会出现乱码。

为了解决这一问题,试想如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。这就是 Unicode!

Unicode 只是一个字符集,只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。UTF-8 是 Unicode 的实现方式之一

UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。

UTF-8 的编码规则很简单,只有二条:

  • 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的
  • 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码
Unicode符号范围(十六进制) UTF-8编码方式(二进制)
0000 0000-0000 007F 0xxxxxxx
0000 0080-0000 07FF 110xxxxx 10xxxxxx
0000 0800-0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

比如这个汉字,它的Unicode编码是0x6c60,处在第三行的范围内(0000 0800 - 0000 FFFF),UTF-8编码需要使用3个字节(1110xxxx 10xxxxxx 10xxxxxx)。首先,将0x6c60转成二进制0b110110001100000(按照模板整理110 110001 100000),然后,从最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0,最终得到这个汉字的UTF-8编码11100110 10110001 10100000,十六进制为0xe6b1a0

通过Python代码反解出

print(b'\xe6\xb1\xa0'.decode('utf8'))  # 打印结果为 

base64编码

Base64是一种用64个字符来表示任意二进制数据的方法(不具备加密功能)

网上关于base64的文章很多,例如阮一峰老师的这篇博客就写的很详细

通过这个汉字的base64编码过程加深自己的理解(utf8中汉字编码为3个字节)

十六进制 e6 b1 a0
二进制 1 1 1 0 0 1 1 0 1 0 1 1 0 0 0 1 1 0 1 0 0 0 0 0
对二进制分组扩展 00 111001 00 101011 00 000110 00 100000
索引下标(十进制) 57 43 6 32
base64编码 5 r G g

base64转化后的结果会比之前大 1/3,常用于在URL、Cookie、网页中传输少量二进制数据

引用

9.文件流

stream(流)

stream 是 Node.js 提供的一个仅在服务区端可用的模块,目的是支持“流”这种数据结构。

什么是流?流是一种抽象的数据结构。有些流用来读取数据,比如从文件读取数据时,可以打开一个文件流,然后从文件流中不断地读取数据。有些流用来写入数据,比如向文件写入数据时,只需要把数据不断地往文件流中写进去就可以了。

在Node.js中,流也是一个对象,我们只需要响应流的事件就可以了:data事件表示流的数据已经可以读取了,end事件表示这个流已经到末尾了,没有数据可以读取了,error事件表示出错了。

文件流读写示例

  • 可读流
const fs = require('fs');
const path = require('path');

// 打开一个可读流流
var rs = fs.createReadStream(path.resolve(__dirname, 'sample.txt'));

rs.on('open', function (fd) {
    console.log(fd);
});

rs.on('data', function (chunk) {
    console.log('DATA: ', chunk)
});

rs.on('end', function () {
    console.log('END');
});

rs.on('error', function (err) {
    console.log('ERROR: ' + err);
});

rs.on('close', function () {
    console.log('close')
});

/* 输出的结果
20
DATA:  <Buffer 7a 69 6a 75 65 20 e7 b4 ab e7 8f 8f>
END
close
*/
  • 可写流
const fs = require('fs');
const path = require('path');

// 打开一个可写流
let ws = fs.createWriteStream(path.resolve(__dirname, 'sample.txt'));

ws.on('close', () => {
    console.log('close')
});

ws.write('紫珏', 'utf8', () => {
    console.log(1);
});
ws.write('紫珏', 'utf8', () => {
    console.log(2);
});

/* 输出的结果
1
2
close
*/

可读流核心原理

可读流(ReadStream)与可写流(WriteStream)均是基于 events 模块

ReadStream继承于Readable,实现了自己的_read()方法。下面我们手写ReadStream原理,不完全按照源码拆分,全部写在ReadStream中方便理解

// ReadStream.js

const EventEmitter = require('events');
const fs = require('fs');

class ReadStream extends EventEmitter {
    constructor(path, options = {}) {
        super();
        this.path = path;
        this.flags = options.flags || 'r';
        this.encoding = options.encoding || null;
        this.autoClose = options.autoClose || true;
        this.start = options.start || 0;
        this.end = options.end || undefined;
        this.highWaterMark = options.highWaterMark || 64 * 1024; // 每次读取的字节
        this.offset = 0;
        /* 
            1.对默认值进行操作
            2.打开文件
            3.当绑定data事件时调用_read方法
        */
        this.open(); // fs.open 异步api,没发直接拿到fd
        this.on('newListener', (type) => {
            if (type === 'data') {
                this._read(); // 真正读取的方法
            }
        })
    }
    destroy(err) {
        if (err) this.emit('error', err);
        if (typeof this.fd === 'number') {
            fs.close(this.fd, () => {
                this.emit('close');
            })
        }
    }
    open() {
        fs.open(this.path, this.flags, (err, fd) => {
            if (err) return this.destroy(err);
            this.fd = fd;
            this.emit('open', fd);

            // 源码中是打开后立即进行读取操作
        })
    }
    _read() { // 此方法中调用 fs.read
        if (typeof this.fd !== 'number') { // 由于fs.open方法是异步的,所以当调用_read方法时文件可能还没有打开
            return this.once('open', () => this._read()); // 绑定open事件,当文件打开时通知执行_read方法
        }
        let buf = Buffer.alloc(this.highWaterMark); // 每次读取的个数
        /* howMuchToRead
                        假设读取 1234567890 且 highWaterMark = 3
        没有传递 end      123 456 789 0
        传递 end = 4     123 45     备注:不知道为什么end设计为全闭区间
        */
        let howMuchToRead = this.end ? Math.min(this.highWaterMark, this.end - this.offset + 1) : this.highWaterMark;
        // 调用真实的read
        fs.read(this.fd, buf, 0, howMuchToRead, this.offset, (err, byteRead) => { // byteRead 真实读到的字节数
            if (err) return this.destroy(err);
            if (byteRead > 0) {
                this.emit('data', buf.slice(0, byteRead));
                this.offset += byteRead;
                this._read();
            } else {
                this.emit('end');
                this.destroy(); // 读取完毕后出发文件操作符关闭操作
            }
        });
    }
}

module.exports = ReadStream;

以上代码就是ReadStream核心原理。但是,将可读流类比于水龙头放水,我们还需要添加暂停与恢复的功能,需添加一个flowing状态标识

// ReadStream.js

const EventEmitter = require('events');
const fs = require('fs');

class ReadStream extends EventEmitter {
    constructor(path, options = {}) {
        super();
        this.path = path;
        this.flags = options.flags || 'r';
        this.encoding = options.encoding || null;
        this.autoClose = options.autoClose || true;
        this.start = options.start || 0;
        this.end = options.end || undefined;
        this.highWaterMark = options.highWaterMark || 64 * 1024; // 每次读取的字节
        this.offset = 0;
        this.flowing = false; // 默认不是流动模式
        /* 
            1.对默认值进行操作
            2.打开文件
            3.当绑定data事件时调用_read方法
        */
        this.open(); // fs.open 异步api,没发直接拿到fd
        this.on('newListener', (type) => {
            if (type === 'data') {
                this.flowing = true;
                this._read(); // 真正读取的方法
            }
        })
    }
    destroy(err) {
        if (err) this.emit('error', err);
        if (typeof this.fd === 'number') {
            fs.close(this.fd, () => {
                this.emit('close');
            })
        }
    }
    open() {
        fs.open(this.path, this.flags, (err, fd) => {
            if (err) return this.destroy(err);
            this.fd = fd;
            this.emit('open', fd);

            // 源码中是打开后立即进行读取操作
        })
    }
    _read() { // 此方法中调用 fs.read
        if (typeof this.fd !== 'number') { // 由于fs.open方法是异步的,所以当调用_read方法时文件可能还没有打开
            return this.once('open', () => this._read()); // 绑定open事件,当文件打开时通知执行_read方法
        }
        let buf = Buffer.alloc(this.highWaterMark); // 每次读取的个数
        /* howMuchToRead
                        假设读取 1234567890 且 highWaterMark = 3
        没有传递 end      123 456 789 0
        传递 end = 4     123 45     备注:不知道为什么end设计为全闭区间
        */
        let howMuchToRead = this.end ? Math.min(this.highWaterMark, this.end - this.offset + 1) : this.highWaterMark;
        // 调用真实的read
        fs.read(this.fd, buf, 0, howMuchToRead, this.offset, (err, byteRead) => { // byteRead 真实读到的字节数
            if (err) return this.destroy(err);
            if (byteRead > 0) {
                this.emit('data', buf.slice(0, byteRead));
                this.offset += byteRead;
                if (this.flowing) this._read(); // 如果是流动模式才继续下一轮的读取
            } else {
                this.emit('end');
                this.destroy(); // 读取完毕后出发文件操作符关闭操作
            }
        });
    }
    pause() {
        this.flowing = false;
    }
    resume() {
        if (!this.flowing) { // 如果不是流动模式则恢复读取
            this.flowing = true;
            this._read(); // 继续读取
        }
    }
}

完整代码及测试链接:手写可读流核心原理

画图加深一下印象

可写流核心原理

// WriteStream.js

const EventEmitter = require('events');
const fs = require('fs');

class WriteStream extends EventEmitter {
    constructor(path, options = {}) {
        super();
        this.path = path;
        this.flags = options.flags || 'w';
        this.encoding = options.encoding || 'utf8';
        this.emitClose = options.emitClose || true;
        this.start = options.start || 0;
        this.highWaterMark = options.highWaterMark || 64 * 1024;
        /* highWaterMark 字段解释
            期望使用多少字节完成写入操作:
                如果超出后 write 的返回值会变成 false
                    返回的 false 可以用于判断,告知用户不要再写入了,再写入只能放到内存中,占用内存
        */
        this.writing = false; // 默认不是正在写入,第一次调用 write 的时候需要执行 fs.write 方法
        this.length = 0; // 表示写入的个数,写入后需要进行减少
        this.needDrain = false; // 是否触发 drain 事件
        this.offset = this.start; // 写入的位置偏移量
        this.cache = [];

        this.open();
    }
    destroy(err) {
        if (err) this.emit('error', err);
        if (typeof this.fd === 'number') {
            fs.close(this.fd, () => {
                this.emit('close');
            })
        }
    }
    open() {
        fs.open(this.path, this.flags, (err, fd) => {
            if (err) this.destroy(err);
            this.fd = fd;
            this.emit('open', fd);
        })
    }
    clearBuffer() {
        // 清空缓存
        let data = this.cache.shift();
        if (data) {
            this._write(data.chunk, data.encoding, data.clearBuffer);
        } else {
            this.writing = false; // 后续的第一次操作继续向文件中写入
            if (this.needDrain) {
                this.needDrain = false;
                this.emit('drain')
            }
        }
    }
    write(chunk, encoding = this.encoding, cb = () => { }) {
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); // 将 chunk 统一转成 buffer
        this.length += chunk.length;
        let result = this.length < this.highWaterMark; // 判断写入的长度与阀值的对比,false表示超出最大写入阀值,再调用write方法只能写入内存
        this.needDrain = !result; // 超过预期或者达到预期需要触发

        const clearBuffer = () => {
            this.clearBuffer();
            cb()
        }

        if (this.writing) {
            // 将写入的内容缓存起来
            this.cache.push({
                chunk,
                encoding,
                clearBuffer
            })
        } else {
            this.writing = true; // 正在写入
            this._write(chunk, encoding, clearBuffer);
        }
        return result;
    }
    _write(chunk, encoding, cb) {
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this._write(chunk, encoding, cb))
        }
        fs.write(this.fd, chunk, 0, chunk.length, this.offset, (err, written) => {
            if (err) return this.destroy(err);
            this.offset += written; // 每次更新偏移量
            this.length -= written; // 减少缓存数量
            cb(); // 当前写入后,需要清空缓存
        })
    }
    end(chunk, encoding = this.encoding, cb = () => { }) {
        /**
         * end = write + close 此处仅为简单示意,存在bug,源码实现较为复杂有时间再梳理
         */
        this.write(chunk, encoding, ()=>{
            this.destroy();
            cb();
        })
    }
}

module.exports = WriteStream;

完整代码及测试链接:手写可写流核心原理

同样画图加深一下印象

下图为调用ws.write(data, encoding, callback)写入文件的详细过程

当多次调用可写流写入时,第一次调用直接写入文件中,其余写入放入缓存队列中;当第一次文件写入成功后调用清理缓存函数clearBuffer,从队列中取出一个缓存的写入任务执行文件写入操作,成功后再次调用回调函数clearBuffer,直至清空缓存队列。后续写入再次进入开头阶段 ~

  • rs.pipe(ws);用于解决大文件读写(边读边写入)
class ReadStream extends EventEmitter {
    ...
    pipe(ws){
        this.on('data', (chunk) => {
           let r =  ws.write(chunk);
           if(!r){
               this.pause();
           }
        });
        this.on('end',()=>{
            ws.end()
        })
        ws.on('drain',()=>{
            this.resume();
        })
    }
    ...
}

pipe方法就是用一根虚拟管道将可读流与可写流连接起来,使数据可以平滑的从一个文件读取并写入到另一个文件中。实现原理:
rs绑定data事件,回调函数调用ws写入数据,当写入数据总量达到highWaterMark阀值后暂停读取,直到ws消费所有写入任务触发drain事件,恢复rs读取操作,如此往复。最后当rs读取完所有数据后,触发rsend事件调用wsend()函数

流的类型

流总共有四种类型:可读流、可写流、双工流、转化流

在之前可读流与可写流的源码学习中,可以知道它们继承于ReadableWriteable

class ReadStream extends Readable {
    _read() {
        // ...
    }
}

class WriteStream extends Writable {
    _write(chunk, encoding, cb) {
        // ...
    }
}

之后调用rsws实例方法时,首先会调用父类的readwrite方法,然后父类会反向调用子类的_read_write方法。同样的,双工流与转化流的实现方式也是一样的

class DuplexStream extends Duplex {
    _read() {
        // ...
    }
    _write(chunk, encoding, cb) {
        // ...
    }
}

class TransformStream extends Transform {
    _transform(chunk, encoding, cb) {
        // ...
    }
}

其中转化流使用的还是比较的多,应用场景有:压缩,转码等操作

下面我们通过一个例子来了解其使用,将终端中输入的内容转化为大写

// 转化流 -- 在对输入的过程进行一个转化操作,将输入的值,转化成大写的
class MyTransform extends Transform{
    _transform(chunk, encoding, cb){ // 参数和可写流一样
        chunk = chunk.toString().toUpperCase();
        this.push(chunk);
        cb();
    }
}
let transform = new MyTransform();

process.stdin.pipe(transform).pipe(process.stdout);

// process.stdin 标准输入
// process.stdout 标准输出

// 终端启动效果如下
$ node streamtypes.js
zijue123
ZIJUE123

4.runtime-dom模块初识

VueRuntimeDOM介绍

Vue中将runtime模块分为runtime-core核心代码及其他平台对应的运行时,那么VueRuntimeDOM无疑就是解决浏览器运行时的问题,此包中提供了DOM 属性操作和节点操作一系列接口。

patchProp的实现

此方法主要针对不同的属性提供不同的patch操作。

const patchClass = (el, next) => {
    if (next == null) {
        next = ''
    }
    el.className = next;
}
const patchStyle = (el, prev, next) => {
    if (next == null) {
        el.removeAttribute('style'); // 如果最新的没有样式 直接移除样式就可以了
    } else {
        if (prev) {
            for (let key in prev) {
                if (next[key] == null) {
                    el.style[key] = ''
                }
            }
        }
        // 新的一定要生效
        for (let key in next) {
            el.style[key] = next[key];
        }
    }
}
const createInvoker = (fn) => { // 借用引用类型实现更新绑定事件执行函数时,不需要解绑的功能
    const invoker = (e) => { invoker.value(e) };
    invoker.value = fn;
    return invoker
}
const patchEvents = (el, key, next) => { // react中采用的是事件代理,但是vue中直接绑定给元素的
    // 之前绑定的事件 和之后绑定的不一样如何处理?
    const invokers = el._vei || (el._vei = {});
    const exists = invokers[key];
    if (exists && next) {
        exists.value = next; // 替换事件 但是不用解绑
    } else {
        const eventName = key.toLowerCase().slice(2); // onClick => click
        if (next) {
            // 绑定事件
            let invoker = invokers[key] = createInvoker(next);
            el.addEventListener(eventName, invoker)
        } else {
            el.removeEventListener(eventName, exists);
            invokers[key] = null;
        }
    }
}
const patchAttrs = (el, key, next) => {
    if (next == null) {
        el.removeAttribute(key);
    } else {
        el.setAttribute(key, next);
    }
}

export const patchProp = (el, key, prev, next) => {
    switch (key) {
        case 'class': // .className  patchProp(el,'class','xxx',null)
            patchClass(el, next);
            break;
        case 'style': // .style.xxx  patchProp('div','style',{color:'red'},{background:'blue'});
            patchStyle(el, prev, next);
            break;
        default:
            if (/^on[A-Z]/.test(key)) {
                // 事件 addEventListener
                patchEvents(el, key, next);
            } else {
                // 其他属性 直接使用setAttribute
                patchAttrs(el, key, next);
            }
    }
}

nodeOps的实现

存放着所有的节点操作的方法

export const nodeOps = {
    // 增 删  改 查询 元素中插入文本  文本的创建  文本元素的内容设置  获取父亲  获取下一个元素
    createElement: tagName => document.createElement(tagName),
    remove: child => child.parentNode && child.parentNode.removeChild(child),
    insert: (child, parent, anchor = null) => parent.insertBefore(child, anchor), // anchor 不存在的时候就是appendChild
    querySelector: selector => document.querySelector(selector),
    setElementText: (el, text) => el.textContent = text,
    createText: text => document.createTextNode(text),
    setText: (node, text) => node.nodeValue = text,
    parentNode: (node) => node.parentNode,
    nextSibling: (node) => node.nextElementSibling
}

runtime-dom的实现

用户调用的createApp函数就在这里被声明。runtime-dom的主要作用就是为了抹平平台的差异。

runtime-dom/src/index.ts

import { createRenderer } from "@vue/runtime-core";
import { extend } from "@vue/shared";
import { nodeOps } from "./nodeOps";
import { patchProp } from "./patchProp";

// runtime-dom 主要的作用就是为了抹平平台的差异,不同平台对dom操作方式是不同的,
// 将api传入runtime-core,core中可以调用这些方法
const rendererOptions = extend(nodeOps, { patchProp });

/** 用户调用createApp方法,此时才会创建渲染器
 * 1.用户传入组件和属性
 * 2.需要创建组件的虚拟节点(diff算法)
 * 3.将虚拟节点变成真实节点
 */
export function createApp(rootComponent, rootProps = null) {
    let app = createRenderer(rendererOptions).createApp(rootComponent, rootProps);
    let { mount } = app;
    // 使用AOP切片的方式,重载mount方法,添加处理逻辑
    app.mount = function (container) {
        container = rendererOptions.querySelector(container);
        container.innerHTML = ''; // 在runtime-dom重写mount方法时,会对容器进行清空
        mount(container); // 执行runtime-core中的mount挂载方法
    }
    return app;
}

runtime-core的实现

runtime-core/src/renderer.ts

import { createAppAPI } from "./apiCreateApp"

export function createRenderer(rendererOptions) { // 不再关心是什么平台,dom操作的方法由runtime-dom传入
    const render = (vnode, container) => {
        console.log('render: ', vnode, container);
    };
    return {
        createApp: createAppAPI(render),
        render
    }
}

runtime-core/src/apiCreateApp.ts

import { createVnode } from "./vnode";

export function createAppAPI(render) {
    return (rootComponent, rootProps) => {
        const app = {
            _component: rootComponent, // 为了稍后组件挂载之前可以先校验组件是否有render函数或模板
            _props: rootProps,
            _container: null,
            mount(container) {
                app._container = container;
                // 1.根据用户传入的组件生成一个虚拟节点
                const vnode = createVnode(rootComponent, rootProps);
                // 2.将虚拟节点变成真实节点,插入到对应的容器中
                render(vnode, container);
            }
        };
        return app
    }
}

runtime-core/src/vnode.ts

import { isObject, isString, ShapeFlags } from "@vue/shared";

export function createVnode(type, props, children = null) {
    /** 虚拟节点? 描述真实节点的对象。
     * 1.虚拟节点的好处就是可以跨平台;
     * 2.如果后续操作可以都在虚拟dom上进行操作,最后一起更新页面,在真实dom之前的一个缓存
     */
    const shapeFlag = isString(type) ? // h('h1', {}, 'xxx')
        ShapeFlags.ELEMENT : isObject(type) ?
            ShapeFlags.STATEFUL_COMPONENT : 0
    const vnode = {
        __v_isVnode: true,
        type, // 组件 || 标签:对于组件而言,组件的type就是一个对象
        props,
        children, // 组件的children是插槽
        key: props && props.key,
        el: null, // 对应一个真实的节点
        shapeFlag,
    }
    normalizeChildren(vnode, children); // 将子节点的类型统一记录在vnode中的shapeFlag中
    return vnode;
}
function normalizeChildren(vnode, children) {
    let type = 0;
    if (children == null) {

    } else if (isArray(children)) {
        type = ShapeFlags.ARRAY_CHILDREN; // 数组
    } else {
        type = ShapeFlags.TEXT_CHILDREN;  // 文本
    }
    vnode.shapeFlag |= type
}

这里ShapeFlags是一个枚举类型,用于标识虚拟节点的类型:

// 用于组合
export const enum ShapeFlags {
    ELEMENT = 1, // 标识是一个元素
    FUNCTIONAL_COMPONENT = 1 << 1, // 函数组件
    STATEFUL_COMPONENT = 1 << 2, // 带状态的组件
    TEXT_CHILDREN = 1 << 3, // 这个组件的孩子是文本
    ARRAY_CHILDREN = 1 << 4, // 孩子是数组
    SLOTS_CHILDREN = 1 << 5, // 插槽孩子
    TELEPORT = 1 << 6, // 传送门
    SUSPENSE = 1 << 7, // 实现异步组件等待
    COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 是否需要keep-alive
    COMPONENT_KEPT_ALIVE = 1 << 9, // 组件的keep-alive
    COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

通过位运算的方式,可以十分方便的进行组合及验证,比如:

FUNCTIONAL_COMPONENT = 1 << 1    // 0b010    2
STATEFUL_COMPONENT = 1 << 2          // 0b100    4
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT    // 0b1000    6

// 判断虚拟节点类型是否为函数组件,就可以将节点的shapeFlag与FUNCTIONAL_COMPONENT按位与操作
shapeFlag & FUNCTIONAL_COMPONENT    // 大于0是函数组件,等于0则不是
// 通过按位或操作,则可以进行组合,例如上面的COMPONENT

上面这种**常用于权限模块的组合与校验,例如Linux文件权限系统。

Vue3初始化流程

用户通过runtime-dom提供的createApp方法创建组件并挂载。runtime-dom将dom操作的api作为参数传递给runtime-core中的createRenderer方法创建渲染器,渲染器会提供两个方法rendercreateApp,这个createApp方法以render函数为参数返回给用户带mountapp对象,当用户执行mount进行节点挂载时,此方法就会:1.根据用户传入的组件生成一个虚拟节点,2.将虚拟节点变成真实节点,插入到对应的容器中。

作用域、执行上下文、作用域链

最近学习前端知识看到一个大佬写的 blog 感觉特别的棒,于是在阅读和学习之后打算总结( 😂 )到自己的知识体系中,建议直接看大佬的总结
关于作用域链部分内容,参考了另一位大佬的总结

作用域(scope)

作用域即函数或变量的可见区域。即函数或者变量不在这个区域内就无法访问到。

函数作用域

用函数形式以 function(){...} 类似的代码包起来的 ...(省略号)区域,即函数作用域

//全局作用域

function func(){//作用域A
    var a = "coffe";

    (function(){//作用域B。一个IIFE形式的函数,把不想公开的内容隐藏起来
        var a = "1891";
        var b = "b";

        //这里可以放有很多其他要对外隐藏的内容:变量或者函数
        //……
        //…...

        console.log(a);
    })();//>> 1891

    console.log(a);//>> coffe
    console.log(b);//>> Uncaught ReferenceError: b is not defined
}

上述代码中,用一个 IIFE 加匿名函数的写法,把变量 b 隐藏起来,函数外面就没法访问它,函数内部可以访问到它。在任何时候都尽量用匿名函数把要调试的代码片段包起来,然后用 IIFE 的形式立即执行。

块级作用域(ES6添加)

ES6 规定,在某个花括号对 {} 的内部用 let 关键字声明的变量或函数拥有块级作用域

处于向后(backward)兼容的考虑,在块级作用域中声明的函数依然可以在作用域外部引用;如果需要函数只在块级作用域中起作用,应该用 let 关键字写成函数的表达式形态,具体看下面这段代码。

{
  function func(){//函数声明
    return 1;
  }
}
console.log(func());//>> 1
// 等同于下面这段代码
{
  var func = function (){//未使用let关键字的函数表达式
    return 1;
  }
}
console.log(func());//>> 1

// 在花括号对 {} 内部由 let 关键字声明的函数,才是真正的处于块级作用域内部
{
  let func = function (){
    return 1;
  }
}
console.log(func());//>> func is not defined

为什么要引进块级作用域

var 声明的变量有副作用:声明提前

(function() {
  console.log(a); //>> undefined
  console.log(b); //>> ReferenceError
  var a = "coffe"; //声明提前
  let b = "1891"; //由let关键字声明的变量,不存在提前的特性
})();

其次,var 声明变量有污染

(function() {
  for (var i = 0; i < 100; i++) {
    //……很多行代码
  }
  function func() {
    //……很多行代码
  }
  //……很多行代码
  console.log(i); //>> 100
})();

// 循环里面的 i 在循环完毕后就没有用了,但并没有被回收掉,而是一直存在的“垃圾”变量,污染了当前的环境。而用 let 声明变量,事后这种垃圾变量会很快被回收掉

(function() {
  for (let i = 0; i < 100; i++) {
    //……很多行代码
  }
  function func() {
    //……很多行代码
  }
  //……很多行代码
  console.log(i); //>> ReferenceError
})();

综上,应该使用 let,尽量避免使用 var,除非想定义一个全局变量。

执行上下文(Execution Context)

定义

执行上下文就是当前 JavaScript 代码被解析和执行时所在的环境,也叫作执行环境;它是一个抽象概念。

JavaScript 中运行任何的代码都是在执行上下文中运行,在该执行上下文的创建阶段,变量对象、作用域链、this 指向会分别被确定。

类型

  • 全局执行上下文:不在任何函数中的代码都位于全局执行上下文中。它做了两件事:1.创建一个全局对象,在浏览器中这个全局对象就是 window 对象;2.将 this 指针指向这个全局对象。一个程序中只能存在一个全局执行上下文
  • 函数执行上下文:每次调用函数时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建。一个程序中可以存在任意数量的函数执行上下文
  • eval 执行上下文:运行在 eval 函数中的代码也获得了自己的执行上下文,ES6 之后不再推荐使用 eval 函数

执行上下文的生命周期

执行上下文的生命周期包括三个阶段:创建阶段 => 执行阶段 => 回收阶段。

创建阶段

当函数被调用,但未执行任何其内部代码之前,会做以下三件事:

  • 创建变量对象:首先初始化函数的参数 arguments,提升函数声明和变量声明(变量的声明提前依赖于 var 关键字)
  • 创建作用域链:在执行期上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象,用于解析变量。当被要求解析变量时,JavaScript 始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量
  • 确定 this 指向

执行阶段

创建完成之后,就会开始执行代码,在这个阶段,会完成变量赋值、函数引用以及执行其他代码。

回收阶段

函数调用完毕后,函数出栈,对应的执行上下文也出栈,等待垃圾回收器回收执行上下文。

画图理解过程

执行上下文栈

var a = "coffe"; //1.进入全局执行上下文
function out() {
    var b = "18";
    function inner() {
        var c = "91";
        console.log(a+b+c);
    }
    inner(); //3.进入inner函数的执行上下文
}
out(); //2.进入out函数的执行上下文

上述代码执行上下文入栈出栈的全过程如图所示:

结合代码与流程图可知:

  • 全局执行上下文在代码开始执行时就创建,有且只有一个,永远在执行上下文栈的栈底,浏览器窗口关闭时他才出栈
  • 函数被调用的时候创建函数的执行上下文环境,并且入栈
  • 只有栈顶的执行上下文才是处于活动状态的,也即只有栈顶的变量对象才会变成活动对象

变量对象(Variable Object,VO)

变量对象是一个类似于容器的对象,与作用域链、执行上下文息息相关,存储了在上下文中定义的变量和函数声明。

不同执行上下文中的变量对象稍有不同,具体看看全局上下文的变量对象和函数上下文的变量对象。

函数执行上下文中的变量对象

创建过程总共有三个阶段:

  • 建立arguments对象。检查当前执行上下文中的参数,建立该对象下的属性与属性值
  • 检查当前执行上下文中的函数声明(function 关键字声明的函数),在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果该属性之前已经存在,那么该属性将会被新的引用所覆盖
  • 检查当前执行上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为 undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为 undefined,则会直接跳过,原属性值不会被修改

当执行上下文进入执行阶段后,变量对象会变为活动对象(Active Object,AO)。此时原先声明的变量会被赋值。变量对象和活动对象都是指同一个对象,只是处于执行上下文的不同阶段

通过伪代码来表示变量对象和活动对象

VO={
    Arguments:{},//实参
    Param_Variable:具体值,//形参
    Function:<function reference>,//函数的引用
    Variable:undefined//其他变量
}

AO={
    Arguments:{},//实参
    Param_Variable:具体值,  //形参
    Function:<function reference>,//函数的引用
    Variable:具体值//注意,这里已经赋值了
}

未进入执行上下文的执行阶段之前,变量对象中的属性都不能访问。但是进入执行阶段后,变量对象被激活转变为了活动对象,里面的属性可以被访问了,然后开始进行执行阶段的操作。

全局执行上下文的变量对象

全局执行上下文的变量对象是 window 对象。

全局执行上下文的生命周期,与程序的生命周期一致,只要程序运行不结束(比如关掉浏览器窗口),全局执行上下文就会一直存在。其他所有的执行上下文,都能直接访问全局执行上下文里的内容。

作用域链(Scope Chain)

多个作用域对应的变量对象串联起来组成的链表就是作用域链,这个链表是以引用的形式保持对变量对象的访问。作用域链保证了当前执行上下文对符合访问权限的变量和函数的有序访问。

当查找变量时,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象(即全局对象)。

下面以一个函数的创建和激活两个时期来说明作用域链是如何创建和变化的。

函数创建

JavaScript 采用词法作用域。函数的作用域在函数定义的时候就决定了。

函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链。

以下面代码为例:

function foo(){
    function bar(){
        ...
    }
}

函数创建时,各自的 [[scope]] 为:

foo.[[scope]] = [
    globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
];

函数激活

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用域链的前端。
这时候执行上下文的作用域链,我们命名为 Scope

Scope = [AO].concat([[Scope]]);

至此,作用域链创建完毕。

流程梳理

以下面代码为例,总结一下函数执行上下文中作用域链和变量对象的创建过程:

var scope1 = "global scope";
function foo(){
    var scope2 = 'local scope';
    return scope2;
}
foo();

执行过程如下:

  • foo 函数被创建,保存作用域链到内部属性 [[scope]]
foo.[[scope]] = [
    globalContext.VO
];
  • 执行 foo 函数,创建 foo 函数执行上下文并入栈
ExecStack = [
    fooContext,
    globalContext
];
  • foo 函数并不会立即执行,而是进入函数执行上下文的创建阶段
  1. 复制函数 [[scope]] 属性创建作用域链
fooContext = {
    Scope: foo.[[scope]],
}
  1. 用 arguments 创建活动对象,并初始化活动对象,加入形参、函数声明、变量声明
fooContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    }
    Scope: foo.[[scope]],
}
  1. 将活动对象压入 foo 作用域链顶端
fooContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: undefined
    },
    Scope: [AO, [[Scope]]]
}
  • 准备工作做完,进入执行阶段,随着函数的执行,修改 AO 的属性值
fooContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope2: 'local scope'
    },
    Scope: [AO, [[Scope]]]
}
  • 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ExecStack = [
    globalContext
];

1.node 初识

node 是什么?能做什么?

  • node 不是语言,它是一个让 js 可以运行在服务端的一个运行时(内置模块提供文件读写、操作系统 api)
  • js 语言组成部分 BOM、DOM、ECMAScript;node 中只包含 ECMAScript + 模块
  • node 静态服务不如 nginx,高并发不如 go;但是 node 做中间层十分合适(解决跨域问题,ssr 的实现,写工具及(egg/nest)后台项目)
  • 高并发(单线程:js 中主线程是单线程,使用回调的方式处理高并发)

非阻塞异步 I/O 特性

  • node 使用 异步非阻塞 + 单线程(主线程)
  • node 中实现了 异步非阻塞库 libuv(多线程来实现的),核心是异步

多线程的优点:可以做压缩合并等大量计算相关的(cpu 密集型);node 适合 I/O 密集型(web 应用的常见)

阻塞非阻塞、同步异步

  • 阻塞与非阻塞针对是调用方,同步异步针对是被调用方
  • 常见的 I/O 模型:同步阻塞、异步非阻塞(当完成任务后以事件的形式通知)

node 的多版本管理

  • mac 可以使用 nvm(brew install nvm)切换 node 的版本
  • node 默认安装会送一个 npm node package manage

默认 node 中的 this 是谁?

  • 在 node 执行环境中的 this 指向的是全局变量 global
  • node + 文件名的方式来执行;在文件中 this 指向的是 module.exports,默认是 {},原因是 CommonJS 规范所有的代码写到文件中,文件内部会自带一个函数,这个函数执行的时候改变了 this

14.koa中间件功能扩展

如何实现一个请求参数解析中间件

Koa中有个包就是专门做这个的,叫koa-bodyparser。它可以将我们请求携带的参数进行解析,接下来我们自己实现其中一些功能。

const querystring = require('querystring');

function bodyParser() {
    return async (ctx, next) => {
        ctx.request.body = await new Promise((resolve, reject) => {
            // 接受请求的信息存入数组,待接受完毕之后运行处理逻辑
            let bufferArr = [];
            ctx.req.on('data', function (chunk) {
                bufferArr.push(chunk);
            });

            ctx.req.on('end', function () {
                let type = ctx.get('content-type');
                let body = Buffer.concat(bufferArr);
                if (type.startsWith('application/x-www-form-urlencoded')) { // 表单格式
                    resolve(querystring.parse(body.toString()));
                } else if (type.startsWith('application/json')) { // json格式
                    resolve(JSON.parse(body.toString()));
                } else if (type.startsWith('text/plain')) { // 纯文本格式,一般需要用户自行判断如何处理该数据
                    resolve(body.toString());
                } else if (type.startsWith('multipart/form-data')) {
                    // todo
                } else {
                    resolve({});
                }
            })
        });
        await next(); // 解析请求体之后,继续向下执行
    }
}
module.exports = bodyParser;

上述代码中,还没有处理form-data格式用于接受文件。我们先试试打印未处理请求的效果是怎么样的:

从图上可以看到请求内容被boundary分隔符分成了好几部分。我们要解析出对应的内容,就需要对接收的请求体二进制数据做切割。但是Buffer中没有split方法,我们可以使用8.Buffer 的应用中实现的split方法。最终完成如下:

const path = require('path');
const fs = require('fs').promises;
const querystring = require('querystring');
const uuid = require('uuid');

Buffer.prototype.split = function (sep) {
    let arr = [];
    let offset = 0; // 偏移位置
    let current = 0; // 当前找到的索引
    let len = Buffer.from(sep).length; // 分隔符真实的长度,单位字节
    while (-1 != (current = this.indexOf(sep, offset))) { // 查找到位置(字节)的索引,只要有继续
        arr.push(this.slice(offset, current));
        offset = current + len;
    }
    arr.push(this.slice(offset));
    return arr;
}

function bodyParser({ dir } = {}) {
    return async (ctx, next) => {
        ctx.request.body = await new Promise((resolve, reject) => {
            // 接受请求的信息存入数组,待接受完毕之后运行处理逻辑
            let bufferArr = [];
            ctx.req.on('data', function (chunk) {
                bufferArr.push(chunk);
            });

            ctx.req.on('end', function () {
                let type = ctx.get('content-type');
                let body = Buffer.concat(bufferArr);
                if (type.startsWith('application/x-www-form-urlencoded')) { // 表单格式
                    resolve(querystring.parse(body.toString()));
                } else if (type.startsWith('application/json')) { // json格式
                    resolve(JSON.parse(body.toString()));
                } else if (type.startsWith('text/plain')) { // 纯文本格式,一般需要用户自行判断如何处理该数据
                    resolve(body.toString());
                } else if (type.startsWith('multipart/form-data')) { // form-data
                    let boundary = '--' + type.split('=')[1]; // content-type中的分隔符比实际传递的少两个'-'
                    let lines = body.split(boundary).slice(1, -1); // 切割之后的数组需要去除头尾
                    let formData = {};
                    lines.forEach(async function (line) {
                        /**
                            Content-Disposition: form-data; name="name"\r\n
                            \r\n
                            zijue
                         */
                        let [head, body] = line.split('\r\n\r\n'); // 规范中定义的key、value之间的填充
                        head = head.toString();
                        let key = head.match(/name="(.+?)"/)[1];
                        if (head.includes('filename')) { // 传递的是文件,需要将文件存储到服务器上
                            /*
                                Content-Disposition: form-data; name="upload"; filename="test.txt"
                                Content-Type: text/plain // 此处结尾有不可见字符 \r\n,共计两个字节
                                 // 此处结尾有不可见字符 \r\n,共计两个字节
                                Zijue

                                520

                                Xiaodai
                                 // 此处结尾有不可见字符 \r\n,共计两个字节
                             */
                            // 所以文件内容的区间就为[head部分长度+4, 行总长-2]
                            let fileContent = line.slice(head.length + 4, -2);
                            dir = dir || path.join(__dirname, 'upload');
                            let filePath = uuid.v4(); // 生成唯一文件名
                            let uploadPath = path.join(dir, filePath);
                            formData[key] = {
                                filename: uploadPath,
                                size: fileContent.length
                            }
                            await fs.writeFile(uploadPath, fileContent);
                        } else {
                            let value = body.toString();
                            formData[key] = value.slice(0, -2); // 去除结尾处的 \r\n
                        }
                    });
                    resolve(formData);
                } else {
                    resolve({});
                }
            })
        });
        await next(); // 解析请求体之后,继续向下执行
    }
}
module.exports = bodyParser;

如何实现koa的静态文件服务器功能

同样的Koa中也有一个专门处理该问题的包,叫koa-static。当用户访问指定静态文件目录时,返回该文件的内容。实现起来很简单,代码如下:

const path = require('path')
const fs = require('fs').promises

async function static(staticPath) {
    return async (ctx, next) => {
        try {
            let filePath = path.join(staticPath, ctx.path);
            let statObj = await fs.stat(filePath);
            if (statObj.isDirectory()) { // 如果是文件夹 会查找index.html
                filePath = path.join(filePath, 'index.html')
            }
            ctx.body = await fs.readFile(filePath, 'utf-8');
        } catch (e) { // 报错说明处理不了 没有这个文件
            await next(); // 继续向下执行
        }
    }
}
module.exports = static;

简单实现Koa路由router的功能

Koa中有一个包@koa/router就是专门处理路由映射问题的。实现原理如下:

class Layer {
    constructor(path, method, callback) {
        this.path = path;
        this.method = method;
        this.callback = callback;
    }
    match(path, method) {
        return this.path == path && this.method == method.toLowerCase()
    }
}

class Router {
    constructor() {
        this.stack = [];
    }
    compose(layers, ctx, next) {
        let dispatch = (i) => {
            if (i == layers.length) return next();
            let callback = layers[i].callback;
            return Promise.resolve(callback(ctx, () => dispatch(i + 1)))
        }
        return dispatch(0);
    }
    routes() {
        return async (ctx, next) => { // app.routes()的返回结果,标准的中间件函数
            let path = ctx.path;
            let method = ctx.method;
            // 当请求来临时,从暂存的栈中过滤出与之相匹配的路径,可能有多个
            let layers = this.stack.filter(layer => layer.match(path, method));
            this.compose(layers, ctx, next);
        }
    }
};

['get', 'post'].forEach(method => [
    Router.prototype[method] = function (path, callback) {
        let layer = new Layer(path, method, callback);
        this.stack.push(layer);
    }
])

module.exports = Router

路由的使用方法:

router = new Router();
router.get('/login', async (ctx, next) => {
    console.log('login-1');
    await next();
})
router.get('/login', async (ctx, next) => {
    console.log('login-2')
    await next()
})
router.post('/login', async (ctx, next) => {
    console.log('login-post')
    await next();
})
app.use(router.routes());

当使用不同请求方式时,会走到不同的处理中间件中。

如何优雅地在koa上扩展业务功能代码

module.exports = function (app) {
    app.use(async (ctx, next) => {
        if (ctx.path === '/login' && ctx.method == 'POST') {
            // 验证用户密码,生成cookie之类的
            console.log('todo');
        } else {
            await next();
        }
    });
}

// 然后在主逻辑代码中引入使用
const login = require('./login');

login(app); // 类似于装饰器的方式

上述代码具体地址koa中间件的使用

4.promise中的其它方法

延迟对象解决嵌套问题

在上一节中为了验证我们手写的promise是否符合规范,在手写的Promise类上添加了Promise.deferred方法。这个deferred是一种编程**,可以用于解决部分嵌套问题。比如下面这段代码:

function readFile(...args) {
    return new Promise((resolve, reject) => {
        fs.readFile(...args, (err, data) => {
            if (err) return reject(err);
            resolve(data);
        })
    })
}

使用deferred可以将代码改写为:

function readFile(...args) {
    let dfd = Promise.deferred();
    fs.readFile(...args, (err, data) => {
        if (err) return dfd.reject(err);
        dfd.resolve(data);
    });
    return dfd.promise
}

改完后的代码少了一层嵌套,好像也没什么用,但是这是一种**。

catchPromise.resolvePromise.reject

  • catch方法
    经常在写Promise时,需要捕获错误,但是then方法中需要传递两个参数;这时就可以用catch方法只捕获错误,同时后面可以继续then
readFile('./xxx.txt', 'uft8').then(data => {
    console.log('data');
}).catch(err => {
    console.log(err);
}).then(data=>{
    console.log('continue'); // 此处会继续执行
})

说白了,catch就是一个没有成功回调方法的then方法,实现代码如下:

class Promise {
    ...
    catch(errFn) {
        return this.then(null, errFn);
    }
    ...
}
  • Promise.resolve静态方法
    有时我们需要直接返回一个成功态的promise,这个时候我们就可以使用Promise.resolve;同时Promise.resolve还具备等待效果,如果我们传入的是一个promise,那么它会将该promise成功或失败的结果向下传递。先来实现传入普通值:
class Promise {
    ...
    static resolve(value) {
        return new Promise((resolve, reject) => {
            resolve(value);
        })
    }
    ...
}

假如我们传入一个Promise,那么结果会是这样:

Promise {
  status: 'FULFILLED',
  value: 'ok',
  reason: undefined,
  onFulfilledCallbacks: [],
  onRejectedCallbacks: []
}

我们写的代码出现这个的原因就是Promise中定义的resolve函数直接将传入的value赋给了promise实例,并将此值作为成功结果执行,因此需要对resolve函数进行修改:

class Promise {
    ...
        const resolve = (value) => {
            if (value instanceof Promise) { // 这个方法并不属于规范中的,只是为了和原生promise表现形式一样
                return value.then(resolve, reject);
            }
            if (this.status == PENDING) {
                this.value = value;
                this.status = FULFILLED;
                this.onFulfilledCallbacks.forEach(fn => fn());
            }
        };
    ...
}
  • Promise.resolve静态方法
    rejectresove实现原理一样,只是不具备等待功能,代码如下:
class Promise {
    ...
    static reject(err) {
        return new Promise((resolve, reject) => {
            reject(err);
        })
    }
    ...
}

Promise.allfinally

  • Promise.all:表示全部成功才成功,如果有一个失败则失败
    核心原理代码如下:
Promise.all = function (promises) {
    return new Promise((resolve, reject) => {
        let results = [];
        let index = 0;
        function process(v, k) { // 与after函数实现原理一致
            results[k] = v;
            if (++index == promises.length) { // 解决多个异步并发问题,只能靠计数器
                resolve(results);
            }
        }
        for (let i = 0; i < promises.length; i++) {
            let p = promises[i];
            if (p && typeof p.then == 'function') {
                p.then(data => { // 异步的
                    process(data, i);
                }, reject);
            } else {
                process(p, i); // 同步的
            }
        }
    })
}
  • finally:无论成功和失败都会执行的方法
    • finally如果返回的是一个promise,那么会有等待效果
    • 只有返回一个失败态的promise,才会将返回的promise失败的原因向下传递,否则传递finally之前的成功结果或失败原因

核心原理代码如下:

Promise.prototype.finally = function (cb) {
    return this.then((y) => {
        return Promise.resolve(cb()).then((d) => y);
    }, (r) => {
        // cb执行一旦报错 就直接跳过后续的then的逻辑,直接将错误向下传递
        return Promise.resolve(cb()).then(() => { throw r })
    })
}

如何将不是Promise的异步API转换成Promise

function promisify(fn) { // 高阶函数
    return function (...args) {
        return new Promise((resolve, reject) => {
            fn(...args, function (err, data) { // node 所有的api第一个参数都是error
                if (err) return reject(err);
                resolve(data);
            })
        })
    }
}

// 测试promisify方法
const fs = require('fs');
let read = promisify(fs.readFile);
read('z1.txt', 'utf8').then(data => {
    console.log(data)
});

promisify可以将所有的回调方法转化成promise,node的api可以使用.promises的方式引入promise的异步方法:

const fs = require('fs').promises;

Promise.race静态方法

race方法,调用的列表中任何一个成功或失败,就采用它的结果。实现原理如下:

Promise.race = function (promises) {
    return new Promise((resolve, reject) => {
        for (let promise of promises) {
            if (promise && typeof promise.then == 'function') {
                promise.then(resolve, reject)
            } else {
                resolve(promise);
            }
        }
    })
}
  • 使用race方法可以实现promise版的具有超时功能的图片懒加载,如下:
let p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('图片加载完成');
    }, 3000);
});
let p2 = new Promise((resolve, reject) => {
    setTimeout(() => {
        reject('请求超时');
    }, 2000);
})

Promise.race([p1, p2]).then(data => {
    console.log(data);
}, err => {
    console.log(err);
})

// 执行结果
$ 请求超时   -- 2s左右
                     -- 3s左右,程序才结束

promise是没法中断执行的,无论如何都会执行完毕,只是不采用这个promise的成功或失败的结果了。

  • 如何在不改变promise原有代码的前提下,提供一个abort中断方法
let p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('图片加载完成');
    }, 3000);
});
function wrap(old) {
    let abort;
    let p2 = new Promise((resolve, reject) => {
        abort = reject; // 内置了一个promise,我们可以控制这个promise,来影响Promise.race的结果
    })
    let returnPromise = Promise.race([old, p2])
    returnPromise.abort = abort;
    return returnPromise
}
let newPromise = wrap(p1);

setTimeout(() => {
    newPromise.abort('超时 2000');
}, 2000);
newPromise.then(data => {
    console.log(data);
}, err => {
    console.log(err)
});

原理就是通过切片编程的**,返回一个新的promise,通过内置的promise影响Promise.race的结果

  • promise是没法中断执行的,但是可以中断链式调用:通过返回一个PENDING状态的Promise
Promise.resolve('1').then(data => {
    console.log(data);
    return new Promise(() => { }); // 返回一个promise,会采用他的状态;如果不成功也不失败,就不会向下执行了
}).then((data) => {
    console.log(data)
});
  • 小结:如果希望不采用原有的结果,可以通过Promise.race;如果希望中断promise的链式调用,则需要返回一个pending状态的promise实现。

pip install 报 [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)

报错信息

Collecting django
  WARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)'),)': /packages/b8/6f/9a4415cc4fe9228e26ea53cf2005961799b2abb8da0411e519fdb74754fa/Django-3.1.7-py3-none-any.whl
  WARNING: Retrying (Retry(total=3, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)'),)': /packages/b8/6f/9a4415cc4fe9228e26ea53cf2005961799b2abb8da0411e519fdb74754fa/Django-3.1.7-py3-none-any.whl
  WARNING: Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)'),)': /packages/b8/6f/9a4415cc4fe9228e26ea53cf2005961799b2abb8da0411e519fdb74754fa/Django-3.1.7-py3-none-any.whl
  WARNING: Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)'),)': /packages/b8/6f/9a4415cc4fe9228e26ea53cf2005961799b2abb8da0411e519fdb74754fa/Django-3.1.7-py3-none-any.whl
  WARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)'),)': /packages/b8/6f/9a4415cc4fe9228e26ea53cf2005961799b2abb8da0411e519fdb74754fa/Django-3.1.7-py3-none-any.whl
ERROR: Could not install packages due to an OSError: HTTPSConnectionPool(host='files.pythonhosted.org', port=443): Max retries exceeded with url: /packages/b8/6f/9a4415cc4fe9228e26ea53cf2005961799b2abb8da0411e519fdb74754fa/Django-3.1.7-py3-none-any.whl (Caused by SSLError(SSLError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:852)'),))

解决办法

临时修复方法

pip install --trusted-host pypi.org --trusted-host files.pythonhosted.org <package_name>

永久解决方法(Linux|Mac)

下面仅演示 virtualenv 环境内部修复步骤

  • 编辑 $VIRTUAL_ENV/pip.conf,没有则新建
$ vim $VIRTUAL_ENV/pip.conf

# 写入以下内容,保存并退出
[global]
trusted-host = pypi.python.org
               pypi.org
               files.pythonhosted.org
  • 再次通过 pip 安装包,发现已经正常

引用参考

12.http应用

http模块

http模块是node中内置模块,可以直接使用。通过const http = require('http')导入的是http1.1,如果想要导入http2.0则需要通过const http2 = require('http2')

http1.1模块本质是流,内部基于tcp(net模块、socket模块),属于半双工流(只能我发请求你响应,你不能主动来找我)。底层基于发布订阅模式,采用socket通信,http会增加一些header信息,请求来了之后需要在socket中读取数据,并解析成请求头。学习http就是学习header、解析请求及响应数据。

http模块的使用方法如下:

const http = require('http');
const url = require('url');

const server = http.createServer((req, res) => {
    console.log('请求来了');
    // ---- 请求行 start ----
    // 先获取请求行、请求方法、请求路径、版本号
    console.log(req.method); // 请求方法是大写
    console.log(req.url); // 请求路径(从路径开始到hash前面,默认没写路径就是/,表示的是服务端根路径)
    console.log(url.parse(req.url));
    // ---- 请求行 end ----
    // ---- 请求头 start ----
    console.log(req.headers); // 获取浏览器的请求头,node中所有的请求头都是小写的
    // ---- 请求头 end ----
    // post 请求和put请求有请求体 req是可读流
    let chunk = [];
    // ---- 读取请求体 start ---- 
    req.on('data', function (data) { // 可读流读取的数据都是buffer类型
        chunk.push(data); // 因为服务端接收到的数据可能是分段传输的,我们需要自己将传输的数据拼接起来
    });
    req.on('end', function () { // 将浏览器发送的数据全部读取完毕
        console.log(Buffer.concat(chunk).toString());
        // ---- 读取请求体 end ---- 
    });

    res.statusCode = 222; // 更改浏览器响应的状态
    res.statusMessage = 'my define';
    res.setHeader('My-Header', 1);
    res.write('hello');
    res.end('ok');
});

let port = 3000;
// 每次更新代码需要重新启动服务,才能运行最新代码
server.listen(port, () => {
    console.log('Server start at ' + port);
});

启动上面的代码之后,通过curl在终端发送一个请求curl --data 'a=1' -X POST -v 127.0.0.1:3000,终端响应结果为:

Note: Unnecessary use of -X or --request, POST is already inferred.
* Uses proxy env variable http_proxy == 'http://127.0.0.1:7890'
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 7890 (#0)
> POST http://127.0.0.1:3000/ HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/7.64.1
> Accept: */*
> Proxy-Connection: Keep-Alive
> Content-Length: 3
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 3 out of 3 bytes
< HTTP/1.1 222 my define
< Transfer-Encoding: chunked
< Connection: keep-alive
< Date: Sun, 30 May 2021 07:47:19 GMT
< Keep-Alive: timeout=4
< My-Header: 1
< Proxy-Connection: keep-alive
<
* Connection #0 to host 127.0.0.1 left intact
hellook* Closing connection 0

服务端打印结果为:

请求来了
POST
/
Url {
  protocol: null,
  slashes: null,
  auth: null,
  host: null,
  port: null,
  hostname: null,
  hash: null,
  search: null,
  query: null,
  pathname: '/',
  path: '/',
  href: '/'
}
{
  host: '127.0.0.1:3000',
  'user-agent': 'curl/7.64.1',
  'content-length': '3',
  accept: '*/*',
  'content-type': 'application/x-www-form-urlencoded'
}
a=1

手写http-server服务

http-server 是一个简单的零配置的命令行http服务器,它足够强大便于生产和使用,用于本地测试和开发。

如图所示,http-server在指定的根路径下启动一个静态服务。如果是文件,点击查看文件内容;如果是文件夹,点击显示文件夹中所有子项。

接下来,我们要自己实现一个简易的http-server。首先,先初始化一个项目:执行npm init,生成package.json文件。

{
  "name": "zijue-http-server",
  "version": "1.0.0",
  "description": "手写http-server",
  "bin": {
    "zijue-hs": "./bin/www"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "zijue",
  "license": "ISC"
}

项目搭建结构如下:

.
├── bin
│   ├── config.js  # 服务启动配置项
│   └── www  # 服务器启动脚本
├── package.json
└── src
    ├── main.js  # 主要代码逻辑
    └── utils.js  # 工具方法
  • src/main.js
const http = require('http');
const fs = require('fs').promises;
const path = require('path');
const url = require('url');
const mime = require('mime'); // 服务器需要在响应头中指定文件类型和编码,不然浏览器解析出错
const chalk = require('chalk'); // 终端显示颜色
const os = require('os');

// 获取本机IP地址
let address = Object.values(os.networkInterfaces()).flat().find(item => item.family == 'IPv4' && item.address != '127.0.0.1').address;

class Server {
    constructor(opts = {}) {
        this.port = opts.port;
        this.directory = opts.directory;
    }
    handleRequest(req, res) {
        // todo
    }
    start() {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(this.port, () => {
            console.log(`${chalk.yellow('Starting up http-server, serving: ')}` + this.directory)
            console.log(`  http://${address}:${chalk.green(this.port)}`)
            console.log(`  http://127.0.0.1:${chalk.green(this.port)}`)
        });
    }
}
module.exports = Server;
  • bin/config.js
const config = {
    'port': {
        option: '-p,--port <n>',
        description: 'set server port',
        default: 8080,
        usage: 'zijue-hs -p <n>'
    },
    'directory': {
        option: '-d,--directory <n>',
        description: 'set server directory',
        default: process.cwd(),
        usage: 'zijue-hs -d <n>'
    }
}

module.exports = config
  • bin/www
#!/usr/bin/env node

// 我们自定义的服务需要支持:改端口号 --port  指定项目启动的根目录 --directory
// 通过 commander 包实现这一目标
const { program } = require('commander');
const config = require('./config');
const { name, version } = require('../package.json');
const Server = require('../src/main');

program.name(name).version(version).usage('[options]');

const usages = [];
Object.entries(config).forEach(([key, value]) => {
    usages.push(value.usage);
    program.option(value.option, value.description, value.default);
})
program.on('--help', function () {
    usages.forEach(usage => console.log('  ' + usage))
});

program.parse(process.argv); // 读取命令行参数并解析

let opts = program.opts();
let server = new Server(opts);

server.start();

至此,我们就初步搭建了服务器的初步结构。在命令行终端中输入npm link便可使用zijue-hs -p 3000启动服务。接下来我们完善Server.handleRequest的功能。

class Server {
    constructor(opts = {}) {
        this.port = opts.port;
        this.directory = opts.directory;
    }
    async handleRequest(req, res) {
        // 1.请求到来的时候,需要监控路径;检查路径是否是文件,如果是文件,直接将文件返回,如果不是,则读取目录
        let { pathname } = url.parse(req.url);
        pathname = decodeURIComponent(pathname); // 请求url中有中文时,url会被浏览器转义成buffer,服务器拿到后无法直接使用,需要解析一下
        let filepath = path.join(this.directory, pathname); // 在当前执行目录下查找
        try {
            let statObj = await fs.stat(filepath);
            if (statObj.isDirectory(filepath)) { // 目录
                let dirs = await fs.readdir(filepath);
                res.setHeader('Content-Type', 'application/json;charset=utf-8')
                res.end(JSON.stringify(dirs));
            } else { // 文件
                this.sendFile(res, filepath);
            }
        } catch (e) {
            this.sendError(res, e)
        }
    }
    sendFile(res, filepath) {
        res.setHeader('Content-Type', mime.getType(filepath) + ';charset=utf-8');
        createReadStream(filepath).pipe(res);
    }
    sendError(res, e) { // 统一错误处理
        console.log('err: ', e);
        res.statusCode = 404;
        res.end('Not Found');
    }
    start() {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(this.port, () => {
            console.log(`${chalk.yellow('Starting up http-server, serving: ')}` + this.directory)
            console.log(`  http://${address}:${chalk.green(this.port)}`)
            console.log(`  http://127.0.0.1:${chalk.green(this.port)}`)
        });
    }
}

上述代码中,访问路径如果是文件则直接返回,如果是文件夹则将文件夹下的所有文件转成json格式返回。但是我们希望如果访问的是文件夹,则通过模板渲染的方式,将文件夹下所有子文件渲染到页面上。

  • 模板渲染引擎的实现原理
    模板引擎的实现原理就是通过with + new Function()将如下的代码:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <%dirs.forEach(item=>{%>
        <%=item.dir%>
    <%})%>
</body>

</html>

包装成如下的函数并执行:

function anonymous(obj) {
    let str = '';
    with (obj) {
        str += `<!DOCTYPE html>
        <html lang="en">
        
        <head>
            <meta charset="UTF-8">
            <meta http-equiv="X-UA-Compatible" content="IE=edge">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>Document</title>
        </head>
        
        <body>
        `
        arr.forEach(item => {
            str += `
            ${item}
            `
        });
        str += `
        </body>
        </html>`
        return str
    }
}

/* 执行测试,正常打印结果
let r = anonymous({ arr: [1, 2, 3, 4, 5] });
console.log(r);
*/

将转换过程封装成render函数,代码如下:

const fs = require('fs').promises;

async function render(tplStr, data) {
    let template = `let str = ''\r\n`;
    template += 'with(obj){';
    template += 'str+=`'
    tplStr = tplStr.replace(/<%=(.*?)%>/g, function () {
        return '${' + arguments[1] + '}'
    });
    template += tplStr.replace(/<%(.*?)%>/g, function () {
        return '`\r\n' + arguments[1] + '\r\nstr+=`'
    });
    template += '`\r\n return str \r\n}'
    let fn = new Function('obj', template);
    return fn(data);
}

module.exports.render = render;

把模板渲染集成到我们写的服务中:

class Server {
    constructor(opts = {}) {
        this.port = opts.port;
        this.directory = opts.directory;
    }
    async handleRequest(req, res) {
        // 1.请求到来的时候,需要监控路径;检查路径是否是文件,如果是文件,直接将文件返回,如果不是,则读取目录
        let { pathname } = url.parse(req.url);
        pathname = decodeURIComponent(pathname); // 请求url中有中文时,url会被浏览器转义成buffer,服务器拿到后无法直接使用,需要解析一下
        let filepath = path.join(this.directory, pathname); // 在当前执行目录下查找
        try {
            let statObj = await fs.stat(filepath);
            if (statObj.isDirectory(filepath)) { // 目录
                let dirs = await fs.readdir(filepath);
                // 使用模板的方式渲染数据
                const template = await fs.readFile(path.resolve(this.directory, 'templates/dir-tpl.html'), 'utf8');
                let content = await render(template, {
                    dirs: dirs.map(dir => ({
                        url: path.join(pathname, dir),
                        dir
                    }))
                })
                res.setHeader('Content-Type', 'text/html;charset=utf-8')
                res.end(content);
            } else { // 文件
                this.sendFile(res, filepath);
            }
        } catch (e) {
            this.sendError(res, e)
        }
    }
    sendFile(res, filepath) {
        res.setHeader('Content-Type', mime.getType(filepath) + ';charset=utf-8');
        createReadStream(filepath).pipe(res);
    }
    sendError(res, e) { // 统一错误处理
        console.log('err: ', e);
        res.statusCode = 404;
        res.end('Not Found');
    }
    start() {
        const server = http.createServer(this.handleRequest.bind(this));
        server.listen(this.port, () => {
            console.log(`${chalk.yellow('Starting up http-server, serving: ')}` + this.directory)
            console.log(`  http://${address}:${chalk.green(this.port)}`)
            console.log(`  http://127.0.0.1:${chalk.green(this.port)}`)
        });
    }
}

到这里,我们就完成了http-server的简单实现。不过我们还可以在此基础上优化其功能,比如:文件压缩传输及静态资源缓存。

  • 文件压缩传输
    要想HTTP请求通过压缩传输文件,需要在客户端请求中设置请求头:accept-encoding: gzip, deflate, br,表示客户端支持gzip, deflate, br三种文件压缩格式,同时服务端可需要设置响应头:content-encoding: gzip,告诉客户端文件使用gzip格式压缩。

之前学习文件流时,最后写过一个简易转大写的转化流,用于将输入的信息全部转化为大写:

// 转化流 -- 在对输入的过程进行一个转化操作,将输入的值,转化成大写的
class MyTransform extends Transform{
    _transform(chunk, encoding, cb){ // 参数和可写流一样
        chunk = chunk.toString().toUpperCase();
        this.push(chunk);
        cb();
    }
}
let transform = new MyTransform();

process.stdin.pipe(transform).pipe(process.stdout);

而我们需要实现的文件压缩功能就可以理解为:读取文件-->压缩文件-->写入res,此过程也是需要在输入到输出的过程中添加一个转化流进行处理。Node.js中提供了一个内置的包zlib就是专门解决这个问题的,现实如下:

    compress(req, res) {
        let encoding = req.headers['accept-encoding'];
        let zip;
        // 此处可以优化为:如果是图片就不要压缩;文件真实类型识别较繁琐,跟主题偏离较远,不做处理。感兴趣可以看此:https://programmer.group/node.js-recognizes-picture-types.html
        if (encoding) {
            let fmts = encoding.split(', ');
            for (let i = 0; i < fmts.length; i++) {
                let lib = fmts[i];
                if (lib == 'gzip') {
                    res.setHeader('content-encoding', 'gzip');
                    zip = zlib.createGzip(); // 此函数返回的就是一个转化流
                    break
                } else if (lib == 'deflate') {
                    res.setHeader('content-encoding', 'deflate');
                    zip = zlib.createDeflate(); // 此函数返回的就是一个转化流
                    break
                }
            }
        }
        return zip;
    }
    sendFile(req, res, filepath) {
        res.setHeader('Content-Type', (mime.getType(filepath) || 'text/plain') + ';charset=utf-8');
        /* 如果进行压缩处理:
            浏览器 -> 服务器    accept-encoding: gzip, deflate, br
            服务器 -> 浏览器    content-encoding: gzip
         */
        let zip = this.compress(req, res);
        if (zip) {
            createReadStream(filepath).pipe(zip).pipe(res);
        } else {
            createReadStream(filepath).pipe(res);
        }
    }
  • 文件缓存的实现
  1. 强制缓存

在响应文件前,可以要求浏览器再次访问此文件时多长时间内不要再来访问我了(只针对引用的资源);首次访问的资源,不会被设置。
设置也比较的简单,只需要设置响应头即可:

res.setHeader('Cache-Control', 'max-age=10'); // 以秒为单位,表示10s内我引用的其它资源不要再来访问了
res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toUTCString()); // 此设置是为了兼容老版本(http1.0)

同时设置以上两种缓存,Cache-Control优先级更高。

  1. 协商缓存

强制缓存有个问题就是,当在到期时间内,文件发生了变化,浏览器无法及时获得最新的文件内容,同时当到达到期时间后,服务器需要再次响应请求发送文件,十分浪费性能。所以,我们采用协商缓存的方式:

    cache(req, res, filepath, statObj) {
        // 协商缓存
        res.setHeader('Cache-Control', 'no-cache'); // 表示每次都来服务器询问(缓存中会存储但每次都发请求);no-store则没有缓存
        let ifModifiedSince = req.headers['if-modified-since']
        let ctime = statObj.ctime.toUTCString();
        res.setHeader('Last-Modified', ctime);
        // 根据最后修改时间,可能会出现时间变化后但内容没变,或者如果1s内多次变化,都没有办法监控
        if (ifModifiedSince != ctime) { // 根据最后修改时间,单位秒
            return false;
        }

        return true;
    }
    sendFile(req, res, filepath, statObj) {
        if (this.cache(req, res, filepath, statObj)) {
            res.statusCode = 304; // 协商缓存需要设置状态码为304
            return res.end(); // 不用返回内容,告诉浏览器找缓存即可
        }

        res.setHeader('Content-Type', (mime.getType(filepath) || 'text/plain') + ';charset=utf-8');
        /* 如果进行压缩处理:
            浏览器 -> 服务器    accept-encoding: gzip, deflate, br
            服务器 -> 浏览器    content-encoding: gzip
         */
        let zip = this.compress(req, res);
        if (zip) {
            createReadStream(filepath).pipe(zip).pipe(res);
        } else {
            createReadStream(filepath).pipe(res);
        }
    }

上述代码中,通过文件最后修改时间设置协商缓存,在响应头中设置Last-Modified,那么当下一次请求来时会携带请求头if-modified-since,值为上次响应头中Last-Modified携带的值:

Response Headers
    Cache-Control: no-cache
    Last-Modified: Fri, 16 Jul 2021 12:49:26 GMT

Request Headers
    If-Modified-Since: Fri, 16 Jul 2021 12:49:26 GMT
  1. Etag

根据最后修改时间,可能会出现时间变化后但内容没变,或者如果1s内多次变化,都没有办法监控。为了解决这一问题,于是我们引入了Etag,先看下面这段代码:

    cache(req, res, filepath, statObj) {
        // Etag --> 根据请求的文件内容生成一个唯一的标识
        res.setHeader('Cache-Control', 'no-cache');
        let ifNoneMatch = req.headers['if-none-match'];
        let etag = crypto.createHash('md5').update(readFileSync(filepath)).digest('base64');
        res.setHeader('Etag', etag);

        // 服务器提供Etag,浏览器请求时就会提供if-none-match
        if (ifNoneMatch != etag) {
            return false;
        }

        return true;
    }
    sendFile(req, res, filepath, statObj) {
        if (this.cache(req, res, filepath, statObj)) {
            res.statusCode = 304; // 协商缓存需要设置状态码为304
            return res.end(); // 不用返回内容,告诉浏览器找缓存即可
        }

        res.setHeader('Content-Type', (mime.getType(filepath) || 'text/plain') + ';charset=utf-8');
        /* 如果进行压缩处理:
            浏览器 -> 服务器    accept-encoding: gzip, deflate, br
            服务器 -> 浏览器    content-encoding: gzip
         */
        let zip = this.compress(req, res);
        if (zip) {
            createReadStream(filepath).pipe(zip).pipe(res);
        } else {
            createReadStream(filepath).pipe(res);
        }
    }

通过代码不难理解,Etag其实就是通过对比文件摘要确认文件是否修改。本示例代码比较暴力,通过对文件所有内容进行摘要,实际上不会这样处理。例如nginx中Etag生成规则如下:

etag = '{:x}-{:x}'.format(header.last_modified, header.content_lenth)  # python伪代码

8.Buffer 的应用

Buffer 简介

JavaScript 语言自身只有字符串数据类型,没有二进制数据类型。

但在处理像TCP流或文件流时,必须使用到二进制数据。因此在 Node.js中,定义了一个 Buffer 类,该类用来创建一个专门存放二进制数据的缓存区。

在 Node.js 中,Buffer 类是随 Node 内核一起发布的核心库,可以让 Node.js 处理二进制数据。

Buffer 对象创建的几种方式

Buffer 代表的是 node 中的二进制(表现给我们的是 16 进制)的内存,大小不能随便更改

let buf1 = Buffer.alloc(10); // node 中的最小单位都是字节,表示申请 10 字节内存空间
let buf2 = Buffer.from([1,2,10,22,18]); // 不常用,表示申请 5 个字节内存空间(数组中数组转换成 16 进制存储)
let buf3 = Buffer.from('小池'); // 首先将‘小池’转换为二进制总共 6 个字节,然后申请 6 个字节内存空间将数据存进去
console.log(buf1, buf2, buf3); // <Buffer 00 00 00 00 00 00 00 00 00 00> <Buffer 01 02 0a 16 12> <Buffer e5 b0 8f e6 b1 a0>

Buffer 的特点

  • Buffer 的长度单位为字节,且声明后长度不能更改
console.log(buf3.length); // 6
  • Buffer 可以和字符串任意转换
console.log(buf3.toString()); // 小池(默认按照 utf-8 编码)
  • Buffer 还可以转换成 base64,不支持 gbk(iconv-lite 处理 node 默认不支持的编码)
console.log(buf3.toString('base64')); // 5bCP5rGg

Buffer 中的方法

  • buffer.slice

buffer类似于数组,有索引、长度,但buffer里存的是内存地址(引用类型)。所以slice方法切片一个新的buffer修改值会改变原buffer的值

let buf4 = Buffer.from([1, 2, 3]);
let buf5 = buf4.slice(0,1); // 在内存地址上截出来某一段位置
console.log(buf4); // <Buffer 01 02 03>
console.log(buf5); // <Buffer 01>
buf5[0] = 100;
console.log(buf4); // <Buffer 64 02 03>
  • buffer.copy

buffer 代表的是内存,不能随便调整大小。需要对内存进行拼接处理,先声明一个更大的Buffer,将多个buffer拷贝上去

let buf1 = Buffer.from('小池');
let buf2 = Buffer.from('同学');
let buf3 = Buffer.alloc(12);
buf1.copy(buf3, 0, 0, 6); // 四个参数:数据拷贝的目标,目标起始位置,源起始位置,源结束位置  默认后两位参数不写拷贝全部数据
buf2.copy(buf3, 6, 0, 6);
console.log(buf3.toString()); // 小池同学
// copy 原理
Buffer.prototype.copy = function(targetBuffer, targetStart, sourceStart = 0, sourceEnd = this.length){
    for (let index = sourceStart; index < sourceEnd; index++) {
        targetBuffer[targetStart++] = this[index]; // 将自身buffer中的值拷贝到目标Buffer上
    }
}
  • Buffer.concat
let concatBuffer = Buffer.concat([buf1, buf2]);
console.log(concatBuffer.toString());
// Buffer.concat 原理
Buffer.concat = function(bufferList, length){
    if (typeof length == 'undefined') {
        length = 0;
        bufferList.forEach(buffer => {
            length += buffer.length;
        })
    }
    let newBuffer = Buffer.alloc(length);
    let offset = 0;
    bufferList.forEach(buffer => {
        buffer.copy(newBuffer, offset);
        offset += buffer.length;
    });
    return newBuffer;
}
  • Buffer.isBuffer
console.log(Buffer.isBuffer(buf3)); // true
console.log(Buffer.isBuffer(123)); // false
  • buffer.indexOf
console.log(buf3.indexOf('池')); // 3 下标索引,单位为字节
  • 自定义split方法(Buffer无此原型方法)

使用slice与indexOf方法自定义split方法,完成对Buffer切分

Buffer.prototype.split = function(sep){
    let arr = [];
    let offset = 0; // 偏移位置
    let current = 0; // 当前找到的索引
    let len = Buffer.from(sep).length; // 分隔符真实的长度,单位字节
    while(-1!=(current = this.indexOf(sep, offset))){ // 查找到位置(字节)的索引,只要有继续
        arr.push(this.slice(offset, current));
        offset = current + len;
    }
    arr.push(this.slice(offset));
    return arr;
}
let buf6 = Buffer.from('小池同学');
let res = buf6.split('池'); //
console.log(res); // [ <Buffer e5 b0 8f>, <Buffer e5 90 8c e5 ad a6> ]
res[0][1] = 100;
console.log(res); // [ <Buffer e5 64 8f>, <Buffer e5 90 8c e5 ad a6> ]  因为使用slice方法,所以改变切分后的Buffer会改变源Buffer的值

函数

最近学习前端知识看到一个大佬写的 blog 感觉特别的棒,于是在阅读和学习之后打算总结( 😂 )到自己的知识体系中,建议直接看大佬的总结

函数是 JavaScript 世界里的一等公民,具有一个特别重要的作用:定义作用域,在 ES6 之前,只有函数才有这个功能,ES6 之后才有 let

函数常见的四种形态

// 函数的声明形态
function func0(){
    console.log("函数的声明形态");
}

// 函数的表达式形态(一)
var func1 = function(){
    console.log("函数的表达式形态");
}
// 函数的表达式形态(二)
(function func2() {})  // 这种函数的声明形态主要用于立即执行函数(IIFE) 

// 函数的嵌套形态
var func3 = function(){
    console.log("函数的嵌套形态");
    let func4 = function(){
        console.log("func4 嵌套在 func3 中");
    }
    func4();
}

// 函数的闭包形态
var func5 = function(){
    let a = "func5";
    return function(){
        console.log("闭包形态函数:" + a);
    }
}

所有的函数都通过一对括号“()”调用,我们称之为调用括号对

函数声明提升

只有声明形态的函数,才具有提升的特性(意思就是代码的执行顺序提升至最前面)

console.log(func0); //>> func0() {return 0}
console.log(func1); //>> undefined
//函数的声明形态
function func0() {
  return 0;
}
//函数的表达式形态
var func1 = function() {
  return 1;
};

IIFE与匿名函数、有名函数

IIFE(Immediately-Invoked Function Expression,立即执行函数)形式的函数调用方式,非常适合匿名函数调用。有两种写法(等效):

(function(){
    console.log("我是立即运行的匿名函数");
})();

(function(){
    console.log("我也是立即运行的匿名函数");
}());

有名函数方便递归,递归需要函数调用自身,函数如果没有名字,就无法有效地通过一个标志符找到函数自身以便供调用。函数的名字可以通过name属性读取到

//函数调用自身称为递归,函数名为“func”
(function func(i){
    console.log("函数名为"+func.name+",第"+i+"次调用")
    if(i<3){//递归出口
        func(++i);//递归
    }
})(1);
//>> 函数名为func,第1次调用
//>> 函数名为func,第3次调用
//>> 函数名为func,第3次调用

匿名函数不利于调试栈追踪,有名函数根据名字可以很快在调试的时候定位代码位置

箭头函数

(参数) => { 表达式 } 这种写法声明一个函数,就叫箭头函数(也叫lamda表达式)。主要意图是定义轻量级的内联回调函数

(function(i){
    console.log(i);
})(1);
// 这两种函数的声明方式等效
((i)=>{
    console.log(i);
})(1);

箭头函数不暴露arguments对象,如下所示:

((a)=>{
    console.log(a);//>> 1
    
    console.log(arguments.length);//>> Uncaught ReferenceError: arguments is not defined
})(1);

箭头函数一个明显作用就是可以保持 this 的指向,总是指向定义它时所在的上下文环境

function func() {
  // 返回一个箭头函数
  return a => {
    //this 继承自 func()
    console.log(this.a);
  };
}
var obj1 = {
  a: 2
};
var obj2 = {
  a: 3
};

var bar = func.call(obj1);
bar.call(obj2); //>> 2         不是 3 !

// func() 内部创建的箭头函数会捕获调用时 func() 的 this。
// 由于 func() 的 this 绑定到 obj1, bar(引用箭头函数)的 this 也会绑定到 obj1,
// this一旦被确定,就不可更改,所以箭头函数的绑定无法被修改。(new 也不行!)

箭头函数不能作为构造函数,因此无法被 new 操作,也就没有 new.target

var Foo = () => {};
var foo = new Foo(); // TypeError: Foo is not a constructor

高阶函数(Higher-order function)

如果一个函数可以接收另一个函数作为参数 或 将另一个函数作为返回值,该函数就称之为高阶函数。
高阶函数最常见的形式之一就是回调函数

function fn1(callback){
    if(callback){
        callback();
    }
}

fn1(function(){
    console.log("高阶函数");//>> 高阶函数
});

函数重载

所谓重载(overload),就是函数名称一样,但是随着传入的参数个数不一样,调用的逻辑或返回的结果会不一样。jQuery之父John Resig曾经提供了一个非常巧妙的思路实现重载,代码如下:

(() => {//IIFE+箭头函数,把要写的代码包起来,避免影响外界,这是个好习惯

    // 当函数成为对象的一个属性的时候,可以称之为该对象的方法。
  
    /**
    * @param {object}  一个对象,以便接下来给这个对象添加重载的函数(方法)
    * @param {name}    object被重载的函数(方法)名
    * @param {fn}      被添加进object参与重载的函数逻辑
    */
    function overload(object, name, fn) {
      var oldMethod = object[name];//存放旧函数,本办法灵魂所在,将多个fn串联起来
      object[name] = function() {
        // fn.length为fn定义时的参数个数,arguments.length为重载方法被调用时的参数个数
        if (fn.length === arguments.length) {//若参数个数匹配上
          return fn.apply(this, arguments);//就调用指定的函数fn
        } else if (typeof oldMethod === "function") {//若参数个数不匹配
          return oldMethod.apply(this, arguments);//就调旧函数
                                                  //注意:当多次调用overload()时,旧函数中
                                                  //又有旧函数,层层嵌套,递归地执行if..else
                                                  //判断,直到找到参数个数匹配的fn
        }
      };
    }
  
    // 不传参数时
    function fn0() {
      return "no param";
    }
    // 传1个参数
    function fn1(param1) {
      return "1 param:" + param1;
    }
    // 传两个参数时,返回param1和param2都匹配的name
    function fn2(param1, param2) {
      return "2 param:" + [param1, param2];
    }
  
    let obj = {};//定义一个对象,以便接下来给它的方法进行重载
    
    overload(obj, "fn", fn0);//给obj添加第1个重载的函数
    overload(obj, "fn", fn1);//给obj添加第2个重载的函数
    overload(obj, "fn", fn2);//给obj添加第3个重载的函数
  
    console.log(obj.fn());//>> no param
    console.log(obj.fn(1));//>> 1 param:1
    console.log(obj.fn(1, 2));//>> 2 param:1,2
})();

原型和原型链

最近学习前端知识看到一个大佬写的 blog 感觉特别的棒,于是在阅读和学习之后打算总结( 😂 )到自己的知识体系中,建议直接看大佬的总结

JavaScript 中除了基础数据类型外的数据类型都是对象(引用类型),没有类(class),为了实现类似继承以便复用代码的能力,JavaScript 采用了原型和原型链的方式。虽然 ES6 提供了 class 关键字构造类,但其实只是语法糖而已,本质上仍然是一个对象。ES6 实现的继承,本质上仍然是基于原型和原型链。

原型、prototype__proto__

首先,我们需要正确的理解原型、prototype、__proto__之间的关系

  • 原型 是一个对象
  • prototype 是函数的一个属性而已,也是一个对象;它和原型没有绝对的关系。JavaScript 里函数也是一种对象,每个对象都有一个原型,但不是所有对象都有 prototype 属性,实际上只有函数才有这个属性
  • 每个对象(实例)都有一个属性 __proto__,指向它的构造函数(constructor)的 prototype 属性
  • 一个对象的原型就是它的构造函数的 prototype 属性的值,因此 __proto__ 也即原型的代名词
  • 对象的 __proto__ 也有自己的 __proto__,层层向上,直到 __proto__null。这种由原型层层链接起来的数据结构为原型链,因为 null 不再有原型,所以原型链的末端是 null

我们通过下面的代码验证一下以上总结

var a = function(){};
var b = [1, 2, 3];

//函数才有 prototype 属性
console.log(a.prototype);  // >> {constructor: ƒ}
//非函数没有 prototype 属性
console.log(b.prototype);  // >> undefined

//a 的构造函数是「Function 函数」,b 的构造函数是「Array 函数」
console.log(a.constructor);  // >> ƒ Function() { [native code] }
console.log(b.constructor);  // >> ƒ Array() { [native code] }

//根据我们上面的总结,a、b 对象的原型(__proto__)分别指向 Function、Array 的 prototype 属性
console.log(a.__proto__ === Function.prototype);  // >> true
console.log(b.__proto__ === Array.prototype);  // >> true

//同时「Function 函数」和「Array 函数」又都是对象,其构造函数是「Object 函数」,所以 a 和 b 的原型的原型都是 Object.prototype
console.log(a.__proto__.__proto__ === Object.prototype);  // >> true
console.log(b.__proto__.__proto__ === Object.prototype);  // >> true

//「Object 函数」作为顶级对象的构造函数,它的实例的原型本身就不再有原型了,因此它原型的 __proto__ 属性为 null
console.log(new Object().__proto__.__proto__);  // >> null
//也即 Object 类型对象,其原型(Object.prototype)的 __proto__ 指向 null
console.log(Object.prototype.__proto__);  // >> null

对象、构造函数和原型三者关系图如下:

原型继承

使用最新的方法 Object.setPrototypeOf 可以很方便地给对象设置原型,这个对象会继承改原型所有属性和方法;
但是,setPrototypeOf 的性能很差,我们应该尽量使用 Object.create() 来为某个对象设置原型

var obj={
    methodA(){
        console.log("coffe");
    }
}

//obj 的原型是 Object.prototype
console.log(obj.__proto__ === Object.prototype);  // >> true

var newObj = Object.create(obj);  //以 obj 为原型创建一个新的对象
//newObj 继承了它的原型对象 obj 的属性和方法
newObj.methodA(); // >> coffe 

原型链的查找机制

当我们访问某个对象的方法或者属性,如果该对象上没有该属性或者方法,JS 引擎就会遍历原型链上的每一个原型对象,在这些原型对象里面查找该属性或方法,直到找到为止,若遍历了整个原型链仍然找不到,则报错

var obj={
    methodA(){
        console.log("coffe");
    }
}

var newObj = Object.create(obj);  //以obj为原型创建一个新的对象
newObj.hasOwnProperty("methodA");  //>> false

上面的代码中,hasOwnProperty 方法并未在 newObj 上定义,也没有在它的原型 obj 上定义,是它原型链上原型 Object.prototype 的方法。其原型链查找顺序如下图所示:

类(class)的 prototype__proto__

ES6之后,增加的 class 本质上是构造函数的语法糖。我们可以通过如下代码进行演示:

class A {
}

typeof A;  // >> "function"
A.prototype;  // >> {constructor: ƒ}

通过上面的代码,可以发现 class 本质上也是函数,类的 prototype 是一个对象,包含有 constructor 属性。这和函数的 prototype 属性表现具有一致性。
而且,类的所有方法都定义在类的 prototype 属性上面。可以看如下代码:

class A {
    constructor() {
        // ...
    }
    toString() {
        // ...
    }
    toValue() {
        // ...
    }
}

console.log(A.prototype);  // {constructor: ƒ, toString: ƒ, toValue: ƒ}

class 作为构造函数的语法糖,同时有 prototype 属性和 __proto__ 属性,因此同时存在两条继承链

  1. 子类的 __proto__ 属性,表示构造函数的继承,总是指向父类
  2. 子类的 prototype 属性的 __proto__ 属性,表示方法的继承,总是指向父类的 prototype 属性
class A {
}

class B extends A {
}

B.__proto__ === A;  //>> true
B.prototype.__proto__ === A.prototype;  //>> true

win10 系统使用 WSL2 搭建开发环境踩坑记录

最近因为工作需要,开发环境需要从 macos 切换到 win10。本人更习惯于类 unix 系统,打算安装 wsl2 并配置开发环境。故将搭建踩坑过程记录下来,如果你遇到了跟我一样的问题,希望本文可以帮助到你

WSL 介绍

适用于 Linux 的 Windows 子系统可让开发人员直接在 Windows 上按原样运行 GNU/Linux 环境(包括大多数命令行工具、实用工具和应用程序),且不会产生传统虚拟机或双启动设置开销

详细介绍可以查看官方文档

安装 WSL2

WSL2 网络不通问题处理

解决办法参考下面两篇文章,按照第一篇文章设置完成后,依旧出现无法ping通宿主机的网卡地址情况,应该是宿主机ICMP功能被限制了,参考第二篇文章

如果 WSL2 虚拟机可以 ping 通宿主机,但是无法 ping 通百度,可以尝试刷新宿主机DNS(ipconfig /flushdns)。参考链接:https://www.v2ex.com/t/797357

如果 WSL2 虚拟机无法 ping 通宿主机,但是可以 ping 通百度,说明是宿主机的防火墙没有设置 WSL 入站规则。可以登录管理员账号执行:New-NetFirewallRule -DisplayName "WSL" -Direction Inbound -InterfaceAlias "vEthernet (WSL)" -Action Allow

相关资料

Mac开发环境nginx配置ssl支持https协议

一、生成私钥(server.key)和 crt 证书(server.crt)

使用 homebrew 安装的 nginx 默认配置文件 nginx.conf 所在目录为/usr/local/etc/nginx

创建存放 SSL 证书相关文件目录

cd /usr/local/etc/nginx
mkdir ssl
cd ssl
  • 生成 server.key
openssl genrsa -des3 -out server.key 2048

以上命令是基于 des3 算法生成的 rsa 私钥,在生成私钥时必须输入至少 4 位的密码

  • 生成无密码的 server.key
openssl rsa -in server.key -out server.key
  • 生成 CA 的 crt
openssl req -new -x509 -key server.key -out ca.crt -days 3650 
  • 基于 ca.crt 生成 csr
openssl req -new -key server.key -out server.csr

命令的执行过程中依次输入国家、省份、城市、公司、部门及邮箱等信息

  • 生成 crt(已认证)
openssl x509 -req -days 3650 -in server.csr -CA ca.crt -CAkey server.key -CAcreateserial -out server.crt

二、配置 nginx 支持 https 协议

  • 编辑 nginx.conf 文件,修改 server 配置
server {
        listen       80;
        server_name  localhost;

        listen       443 ssl;
        ssl_certificate             /usr/local/etc/nginx/ssl/server.crt;
        ssl_certificate_key         /usr/local/etc/nginx/ssl/server.key;
        
        ......

}
  • 重启 nginx
sudo nginx -s reload

三、Mac 配置 ip 域名映射

修改 /etc/hosts 文件,实现映射

vim /etc/hosts

# 添加本地 https 测试域名 ip 映射
127.0.0.1 localdomain.test

此时访问 https://localdomain.test/,提示无法访问。Chrome 也没有“忽略证书继续前往”的选项,需要我们在系统上添加自签名证书到系统并修改为始终信任

四、设置 Mac 信任自签名证书

  • 在浏览器的地址栏,点击不安全的图标,再点击 证书(无效) 进入证书详情
  • 在证书详情对话框中,那个证书的图标是可以拖动的,按住拖动任意文件夹中,会自动保存为一个 *.cer 文件
  • 双击 *.cer 文件,会出来一个安装对话框,选择安装到 “系统” 钥匙串
  • 安装完成后,在系统自带的“钥匙串访问”应用中,找到我们刚安装好的证书,双击它,进入设置详情,把“信任”一栏,全部改为“始终信任”后关闭对话框
  • 刷新浏览器,即可正常访问。Chrome 浏览器中,还需要点击一下“忽略证书继续前往”的选项

拓展:不信任网页暴力解决方法

直接在页面输入 thisisunsafe 即可继续访问

2.promise介绍及初步功能的实现

Promise介绍

MDN中对Promise的定义:Promise 对象用于表示一个异步操作的最终完成 (或失败)及其结果值。

Promise 具备以下一些特点:

  • Promise是一个类,使用new Promise创建一个实例。Promise无需考虑兼容性;
let promise = new Promise((resolve, reject) => {});
  • 当使用Promise的时候,会传入一个执行器executor此执行器是立即执行
let promise = new Promise((resolve, reject) => {
    console.log('此处代码是立即执行的');
 });
 console.log('非promise中代码');

 /** 代码执行结果
  * 此处代码是立即执行的
  * 非promise中代码
  */
  • 当前执行器executor中传入两个函数来描述当前promise的状态。Promise中有三个状态:成功态失败态等待态;默认为等待态,如果调用resolve会走成功态,如果调用reject或发生异常会走失败态;
let promise = new Promise((resolve, reject) => {
    resolve('ok');
});

promise.then((value) => {
    console.log('success', value);
}, (reason) => {
    console.log('fail', reason);
})

 /** 代码执行结果
  * success ok
  */
  • Promise一旦状态发生变化后就不能更改;
let promise = new Promise((resolve, reject) => {
    reject('err');
    resolve('ok');
});

promise.then((value) => {
    console.log('success', value);
}, (reason) => {
    console.log('fail', reason);
})

/** 代码执行结果
 * fail err
 */
  • 每个promise实例都有一个then方法,且支持链式调用。
let promise = new Promise((resolve, reject) => {
    reject('err');
});

promise.then((value) => {
    console.log('success', value);
}, (reason) => {
    console.log('fail', reason);
    return reason;
}).then((value) => {
    console.log('success2', value);
}, (reason) => {
    console.log('fail2', reason);
})

/** 代码执行结果
 * fail err
 * success2 err
 */

Promise初步功能实现

Promises/A+规范文档

首先需要定义三种状态,其次在then方法中传入成功态失败态对应的执行函数;当promise处于非等待态的状态下,执行相应的逻辑,否则将对应的逻辑加入成功态失败态的回调数组中,等待promise状态发生改变时依次调用执行数组中的回调函数。

const PENDING = 'PENDING'; // 默认等待态
const FULFILLED = 'FULFILLED'; // 成功态
const REJECTED = 'REJECTED'; // 失败态

class Promise {
    constructor(executor) {
        this.status = PENDING;
        this.value = undefined;
        this.reason = undefined;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];
        const resolve = (value) => {
            if (this.status == PENDING) {
                this.value = value;
                this.status = FULFILLED;
                this.onFulfilledCallbacks.forEach(fn => fn());
            }
        };
        const reject = (reason) => {
            if (this.status == PENDING) {
                this.reason = reason;
                this.status = REJECTED;
                this.onRejectedCallbacks.forEach(fn => fn());
            }
        };
        try {
            executor(resolve, reject); // 默认 new Promise 中的函数会立即执行
        } catch (e) { // 传入的函数执行出错,将错误传递给 reject,执行失败态的逻辑
            reject(e)
        }
    }
    then(onFulfilled, onRejected) {
        if (this.status == FULFILLED) {
            onFulfilled(this.value);
        }
        if (this.status == REJECTED) {
            onRejected(this.reason);
        }
        if (this.status == PENDING) {
            this.onFulfilledCallbacks.push(() => {
                onFulfilled(this.value);
            });
            this.onRejectedCallbacks.push(() => {
                onRejected(this.reason);
            })
        }
    }
}
module.exports = Promise;

测试一下,可以看出能够处理异步逻辑:

const Promise = require('./promise')

let promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('ok1')
    }, 2000);
});
console.log('ok2')

promise.then((value) => {
    console.log('success', value);
}, (reason) => {
    console.log('fail', reason);
    return reason;
})

/** 代码执行结果
 * ok2
 * success ok1
 */

画个图便于理解:

1.vue3介绍及源码开发环境的搭建

vue3与vue2的对比

  • 源码采用 monorepo 方式进行管理,将模块拆分到package目录中
  • Vue3 采用ts开发,增强类型检测。Vue2 则采用flow
  • Vue3的性能优化,支持tree-shaking,不使用就不会被打包
  • Vue2 后期引入RFC,使每个版本改动可控 rfcs
  • Vue3 劫持数据采用proxy,Vue2 劫持数据采用defineProperty。 defineProperty有性能问题和缺陷
  • Vue3中对模板编译进行了优化,编译时 生成了Block tree,可以对子节点的动态节点进行收集,可以减少比较,并且采用了 patchFlag 标记动态节点
  • Vue3 采用compositionApi 进行组织功能,解决反复横跳,优化复用逻辑 (mixin带来的数据来源不清晰、命名冲突等),相比optionsApi 类型推断更加方便
  • 增加了 Fragment,Teleport,Suspense组件

vue3整体架构

  • monorepo介绍
    Monorepo 是管理项目代码的一个方式,指在一个项目仓库(repo)中管理多个模块/包(package)

    • 一个仓库可维护多个模块,不用到处找仓库
    • 方便版本管理和依赖管理,模块之间的引用,调用都非常方便
    • 缺点:仓库体积会变大
  • vue3项目结构

    • reactivity:响应式系统
    • runtime-core:与平台无关的运行时核心 (可以创建针对特定平台的运行时 - 自定义渲染器)
    • runtime-dom:针对浏览器的运行时。包括DOM API,属性,事件处理等
    • runtime-test:用于测试
    • server-renderer:用于服务器端渲染
    • compiler-core:与平台无关的编译器核心
    • compiler-dom:针对浏览器的编译模块
    • compiler-ssr:针对服务端渲染的编译模块
    • compiler-sfc:针对单文件解析
    • size-check:用来测试代码体积
    • template-explorer:用于调试编译器输出的开发工具
    • shared:多个包之间共享的内容
    • vue:完整版本,包括运行时和编译器
                            +---------------------+
                            |                     |
                            |  @vue/compiler-sfc  |
                            |                     |
                            +-----+--------+------+
                                  |        |
                                  v        v
               +---------------------+    +----------------------+
               |                     |    |                      |
     +-------->|  @vue/compiler-dom  +--->|  @vue/compiler-core  |
     |         |                     |    |                      |
+----+----+    +---------------------+    +----------------------+
|         |
|   vue   |
|         |
+----+----+   +---------------------+    +----------------------+    +-------------------+
    |         |                     |    |                      |    |                   |
    +-------->|  @vue/runtime-dom   +--->|  @vue/runtime-core   +--->|  @vue/reactivity  |
              |                     |    |                      |    |                   |
              +---------------------+    +----------------------+    +-------------------+

vue3模块管理环境搭建

  • yarn初始化项目
    因为我们希望通过monorepo的方式管理项目下的多个模块,npm安装管理模块无法实现此功能,故使用yarn来管理模块。
yarn init -y

修改生成的package.json文件,添加以下内容:

{
  "private": true, // 表示该项目不会发布到npm上,只是用来管理的
  "workspaces": [ // 指定管理的包的路径
    "packages/*"
  ]
}
  • 安装 TypeScript
  1. 在根目录下安装:yarn add typescript -W
  2. 初始化typescriptnpx tsc --init(不带npx表示执行全局下的tsc,加npx执行的是当前目录下node_modules/.bin/tsc命令去初始化)
  3. 修改生成的tsconfig.json文件:
// "module": "commonjs", // rollup不支持打包commonjs
"module": "ESNext", // 一般默认都修改为ESNext
  • 安装依赖
yarn add rollup rollup-plugin-typescript2 @rollup/plugin-node-resolve @rollup/plugin-json execa -D -W

# rollup        打包工具
# rollup-plugin-typescript2     rollup和ts的桥梁
# @rollup/plugin-node-resolve   解析node第三方模块
# @rollup/plugin-json   支持引入json
# execa         开启子进程方便执行命令
  • 项目结构
.
├── package.json            # 项目信息
├── packages                # monorepo方式管理包根路径
│   ├── reactivity          # Vue响应式模块
│   │   ├── package.json
│   │   └── src
│   │       └── index.ts
│   └── shared              # Vue共享模块
│       ├── package.json
│       └── src
│           └── index.ts
├── rollup.config.js        # rollup配置文件
├── scripts                 # 打包脚本
│   └── build.js
├── tsconfig.json           # typescript配置文件
└── yarn.lock
  • 配置模块名称及自定义打包配置
// packages/reactivity/package.json
{
  "name": "@vue/reactivity", //@vue表示命名空间
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "buildOptions": { //自定义打包配置项
    "name": "VueReactivity",
    "formats": [ // 打包的格式
      "esm-bundler",
      "cjs",
      "global"
    ]
  }
}

编译打包环境搭建

  • packages下模块进行打包

scripts/build.js

const fs = require('fs');
const execa = require('execa');

// 1.解析packages目录,过滤出所有的目录(即需要打包的模块)
const dirs = fs.readdirSync('packages').filter(item => {
    if (!fs.statSync(`packages/${item}`).isDirectory()) {
        return false;
    }
    return true;
});

// 2.并行打包所有文件夹
async function build(target) {
    /* 
     * -c 表示使用配置文件rollup.config.js
     * --environment 表示rollup执行时传递环境变量,此处传递的环境变量为`TARGET:${target}`
     * { stdio: 'inherit' } 表示子进程输出打印到父进程标准输出中
     */
    await execa('rollup', ['-c', '--environment', `TARGET:${target}`], { stdio: 'inherit' });
}
async function runParallel(dirs, exec) {
    let result = [];
    for (let item of dirs) {
        result.push(exec(item));
    }
    return Promise.all(result); // 待所有打包操作完毕后,调用成功
}
runParallel(dirs, build).then(() => {
    console.log('打包成功');
})
  • rollup配置

rollup.config.js

import path from 'path';
import ts from 'rollup-plugin-typescript2'; // 解析ts插件
import resolvePlugin from '@rollup/plugin-node-resolve'; // 解析第三方模块

// 获取packages目录
let packagesDir = path.resolve(__dirname, 'packages');
// 获取需要打包模块的路径
let pkgDir = path.resolve(packagesDir, process.env.TARGET);

const resolvePath = item => path.resolve(pkgDir, item); // 将用户传入的路径与打包模块目录合并

// 获取打包模块的package.json文件内容
let pkg = require(resolvePath('package.json'));
// 获取模块自定义参数
let pkgOpts = pkg.buildOptions;
// 获取模块的文件夹名称
let pathName = path.basename(pkgDir);
// 一个包需要打包成多个格式 esModule commonjs iife
const outputConfig = {
    'esm-bundler': {
        file: resolvePath(`dist/${pathName}.esm-bundler.js`),
        format: 'es'
    },
    'cjs': {
        file: resolvePath(`dist/${pathName}.cjs.js`),
        format: 'cjs'
    },
    'global': {
        file: resolvePath(`dist/${pathName}.global.js`),
        format: 'iife'
    }
}
function createConfig(output){
    output.name = pkgOpts.name; // 用于 iife 在 window 上挂载的属性
    output.sourcemap = true; // 生成sourcemap,便于调试。tsconfig.json中也需要开启
    return {
        input: resolvePath('src/index.ts'), // 打包入口
        output,
        plugins: [
            ts({ // ts 编译时配置文件
                tsconfig: path.resolve(__dirname, 'tsconfig.json')
            }),
            resolvePlugin()
        ]
    }
}
// 根据用户打包模块中提供的formats选项,去outputConfig配置里取值生成配置文件
export default pkgOpts.formats.map(format=>createConfig(outputConfig[format]));
  • 配置脚本执行命令

package.json 添加scripts

  "scripts": {
    "build": "node scripts/build.js"
  }

保存后,在项目根目录下命令行执行:npm run build

编译开发打包环境搭建

虽然目前可以成功打包整个项目模块,但是在开发过程中每次调用npm run build很浪费性能,我们更希望可以控制某个模块单独进行打包。于是我们添加一个执行脚本"dev": "node scripts/dev.js"

scripts/dev.js

const execa = require('execa');

async function build(target) {
    // -cw 表示打包并监视文件文件变化,需要打包的模块变化时自动打包
    await execa('rollup', ['-cw', '--environment', `TARGET:${target}`], { stdio: 'inherit' });
}
build('reactivity'); // 仅打包响应式模块

扩展:组件库也可以采用monorepo的方式,好处就是每个组件都可以独立发布。

2.基础类型

TS 中冒号后面的都为类型标识

JavaScript 数据类型分类

  • 原始数据类型:stringnumberbigintbooleannullundefinedsymbol
  • 非原始数据类型:object

布尔值、数字、字符串类型

let bool:boolean = true;
let num:number = 11;
let str:string = 'hello world';

数组类型

声明数组中元素的数据类型,只允许数组存放声明的类型

let arr1:number[] = [1,2,3];
let arr2:string[] = ['1','2','3'];
let arr3:(number|string)[] = [1,'2',3];

元组类型

限制长度个数,类型一一对应

let tuple:[string,number,boolean] = ['zijue',10,true];

元组在初始化赋值之后可以增加数据(只能增加元组中存放的类型),这跟 python 中元组不可变不太一样

let tuple:[string,number,boolean] = ['zijue', 10, true];
tuple.push('xiaochi');  // 添加的元素不能使用索引下标取值

枚举类型

枚举使用 enum 关键字来定义

enum USER_ROLE {
    USER,
    ADMIN,
    MANAGER
}

枚举成员会被赋值为从 0 开始递增的数字,同时也会对枚举值到枚举名进行反向映射

enum USER_ROLE { USER, ADMIN, MANAGER }

console.log(USER_ROLE['USER'] === 0);  // >> true
console.log(USER_ROLE['ADMIN'] === 1);  // >> true
console.log(USER_ROLE['MANAGER'] === 2);  // >> true

console.log(USER_ROLE[0] === 'USER');  // >> true
console.log(USER_ROLE[1] === 'ADMIN');  // >> true
console.log(USER_ROLE[2] === 'MANAGER');  // >> true

事实上,上面的例子会被编译为

var USER_ROLE;
(function (USER_ROLE) {
    USER_ROLE[USER_ROLE["USER"] = 0] = "USER";
    USER_ROLE[USER_ROLE["ADMIN"] = 1] = "ADMIN";
    USER_ROLE[USER_ROLE["MANAGER"] = 2] = "MANAGER";
})(USER_ROLE || (USER_ROLE = {}));
  • 异构枚举

异构枚举的成员值是数字和字符串的混合

enum USER_ROLE {
    USER = 'user',
    ADMIN = 1,
    MANAGER,
}
  • 常量枚举

使用 const enum 定义的枚举类型。与普通枚举的区别是,它会在编译阶段被删除

const enum USER_ROLE {
    USER,
    ADMIN,
    MANAGER,
}
console.log(USER_ROLE.USER);  // 被编译为:console.log(0 /* USER */);

any 类型

不进行类型检查

let arr:any = ['zijue',true,{name:'xiaochi'}]

nullundefined

任何类型的子类型,但是在严格模式下,不能将 nullundefined 赋给其他类型变量

let name:number | boolean;
name = null;

void 类型

只能接受 nullundefined。一般用于函数的返回值

function alertName(): void {
    alert('My name is Tom');
}

let unusable: void = undefined;

严格模式下,不能将 null 赋给 void 类型

never 类型

任何类型的子类型,never 表示不存在的值,不能把其他类型赋值给 never 类型
出现的情况有三种:

  • 错误
  • 死循环
  • 类型判断时会出现 never

symbol 类型

symbol 表示独一无二

const s1 = Symbol('key');
const s2 = Symbol('key');
console.log(s1 == s2);  // >> false

bigint 类型

numberbigint 不兼容

const num1 = Number.MAX_SAFE_INTEGER + 1;
const num2 = Number.MAX_SAFE_INTEGER + 2;
console.log(num1 == num2);  // >> true

let max: bigint = BigInt(Number.MAX_SAFE_INTEGER);
console.log(max + BigInt(1) === max + BigInt(2));  // >> false

object 类型

object 表示非原始数据类型

let create = (obj:object):void=>{}
create({});
create([]);
create(function(){})

10.文件夹删除的方式:异步串行与并发

文件夹删除思路

假设现在有一个文件夹,目录树结构如下:

a
├── b
   └── e
├── c
   └── f
└── d
    ├── g
    └── h

首先最容易想到的就是串行的方式:遇到文件夹先去遍历子节点,当子节点删除完毕后再删除自己,如果子节点也有子节点,则递归

如上图所示,将整个的删除过程串联起来,这也称之为:异步串行。

异步串行

深度优先

上面介绍的处理文件夹删除的**就是异步串行-深度优先**,具体实现代码如下:

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

function myRmdir(dir, callback) {
    fs.stat(dir, (err, statObj) => {
        if (statObj.isFile()) {
            fs.unlink(dir, callback); // 如果是文件直接删除即可
        } else {
            fs.readdir(dir, (err, dirs) => {
                if (dirs.length != 0) {
                    // 1.读取所有的子节点路径
                    dirs = dirs.map(d => path.join(dir, d));
                }
                // 2.依次拿出子节点进行删除操作
                let idx = 0;
                function next() {
                    // 3.当前节点索引与子节长度相同时,表示子节点已删除完毕
                    if (idx == dirs.length) return fs.rmdir(dir, callback);
                    let current = dirs[idx++];
                    myRmdir(current, next);
                }
                next();
            })
        }
    })
}

myRmdir('a1', () => {
    console.log('删除完成');
});

广度优先

异步串行的方式除了深度优先,还有广度优先的**:先采用异步的方式读取目录,维护想要的文件目录关系,最终将结果倒序删除

**如下图所示(先维护关系再倒叙删除):

之后以此内推,将指针指向`e`、`f`、`g`、`h`直到数组遍历完成。代码实现如下:
const fs = require('fs');
const path = require('path');

function myRmdir(dir, callback) {
    stack = [dir];
    function reverseRemove() {
        let idx = stack.length - 1;
        function next() {
            if (idx < 0) return callback();
            let current = stack[idx--];
            fs.rmdir(current, next);
        }
        next();
    }
    fs.stat(dir, (err, statObj) => {
        if (statObj.isFile()) {
            fs.unlink(dir, callback); // 如果是文件直接删除即可
        } else {
            // 如果是目录,采用广度遍历的方式
            // 采用异步的方式读取目录,维护想要的结果,最终将结果倒序删除
            let idx = 0;
            function next() {
                let dir = stack[idx++];
                if (!dir) return reverseRemove();
                fs.readdir(dir, (err, dirs) => {
                    if (dirs.length != 0) {
                        dirs = dirs.map(d => path.join(dir, d));
                    }
                    stack.push(...dirs);
                    next();
                })
            }
            next();
        }
    })
}

myRmdir('a1', () => {
    console.log('删除完成');
});

并发删除

异步串行的方式,明显性能不太高效,需要提高效率则采用并发删除的方式,代码如下:

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

function myRmdir(dir, callback) {
    fs.stat(dir, (err, statObj) => {
        if (statObj.isFile()) {
            fs.unlink(dir, callback); // 如果是文件直接删除即可
        } else {
            // 如果是文件夹,同时删除子节点(如果子节点为空则需要删除自己)
            fs.readdir(dir, (err, dirs) => {
                if (dirs.length != 0) {
                    dirs = dirs.map(d => path.join(dir, d));
                } else {
                    return fs.rmdir(dir, callback); // 没有子节点,删除自身
                }
                let idx = 0;
                function removeCount() {
                    if (++idx == dirs.length) {
                        fs.rmdir(dir, callback);
                    }
                }
                dirs.forEach(dir => {
                    myRmdir(dir, removeCount);
                })
            })
        }
    })
}

myRmdir('a1', () => {
    console.log('删除完成');
});

核心**就是,删除子节点成功时,回调计数器函数,当计数器的值与子节点长度相等时,删除父节点

promise优化

上述并发删除的方式,虽然性能会有提升,但是回调的方式不够优化,采用promise的方法进行优化

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

function myRmdir(dir) {
    return new Promise((resolve, reject) => {
        fs.stat(dir, (err, statObj) => {
            if (err) reject(err);
            if (statObj.isFile()) {
                fs.unlink(dir, resolve);
            } else {
                fs.readdir(dir, (err, dirs) => {
                    if (err) reject(err);
                    // map 返回的是删除子节点列表的promise数据
                    if (dirs.length != 0) {
                        dirs = dirs.map(d => myRmdir(path.join(dir, d)));
                    }
                    Promise.all(dirs).then(() => {
                        fs.rmdir(dir, resolve);
                    }).catch(err => {
                        reject(err);
                    })
                })
            }
        })
    })
}

myRmdir('a1').then(() => {
    console.log('删除成功');
}).catch(err => {
    console.log('删除失败', err);
})

async + await

使用promise优化过后,代码简洁不少,但是还不太够,使用async+await方式进一步优化,将异步操作写的像同步一样

const fs = require('fs').promises;
const path = require('path');

async function myRmdir(dir) {
    let statObj = await fs.stat(dir); // statObj | 不存在则报错
    if (statObj.isDirectory()) {
        let dirs = await fs.readdir(dir); // 返回的是一个数组
        // 使用Promise.all将所有子文件包裹起来进行删除
        await Promise.all(dirs.map(d => myRmdir(path.join(dir, d))));
        await fs.rmdir(dir);
    } else {
        await fs.unlink(dir);
    }
}

myRmdir('a1').then(() => {
    console.log('删除成功');
}).catch(err => {
    console.log('删除失败', err);
})

2.开发环境配置

开发服务器

  • 安装服务器
npm i webpack-dev-server -D
  • 配置webpack.config.jspackage.json

webpack.config.js

module.exports = {
    // ...
    devServer: {
        static: path.resolve(__dirname, 'static'), // 额外的静态文件的根目录,在开发服务器下静态代理static文件夹
        compress: true, // 是否启动压缩
        port: 8080, // 配置http服务预览的端口号,如果不设置,默认就是8080
        open: true // 编译成功后,会自动打开浏览器进行预览
    },
    // ...
}

package.json

  "scripts": {
    "build": "webpack",
+    "dev": "webpack serve"
  },

5.手写渲染流程

初始化渲染逻辑

用户初次调用渲染器提供的render方法时,发生在组件初始化的阶段,此时虚拟节点的类型为组件。

runtime-core/src/renderer.ts

    const mountComponent = (n2, container) => {
        // 组件的初始化
    };
    const processComponent = (n1, n2, container) => {
        if (n1 == null) {
            mountComponent(n2, container); // 创建并挂载组件
        } else {
            updateComponent(n1, n2, container); // 更新组件
        }
    };
    const patch = (n1, n2, container) => {
        const { shapeFlag } = n2; // n2 可能是元素或者组件,不同的类型走不同的处理逻辑
        if (shapeFlag & ShapeFlags.ELEMENT) {
            processElement(n1, n2, container); // 处理元素类型
        } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
            processComponent(n1, n2, container); // 处理组件类型
        }
    };
    const render = (vnode, container) => {
        patch(null, vnode, container); // 初始化逻辑,老的虚拟节点为null;后续更新还有更新逻辑
    };

组件的初始化流程最后走到mountComponent 方法去完成组件的初始化。其中render方法就是调用了patch函数,该函数用于初次渲染及后续更新逻辑,将虚拟节点变成真实节点渲染到页面上。初始化时,patch第一个参数传null

组件的渲染流程

  1. 为组件创建实例instance

runtime-core/src/renderer.ts

    const mountComponent = (n2, container) => {
        // 1.组件的创建,需要产生一个组件的实例,调用组件实例上的setup方法拿到render函数,再调用render函数,拿到组件对应(要渲染的内容)的虚拟DOM、subTree
        let instance = n2.component = createComponentInstance(n2); // 根据虚拟节点创造一个实例
    };

runtime-core/src/component.ts

let uid = 0;
export function createComponentInstance(vnode) {
    const instance = {
        uid: uid++,
        vnode: vnode, // 实例上的vnode就是我们创建并处理过的vnode
        type: vnode.type, // 用户写的组件的内容
        props: {}, // props就是组件里用户声明过的
        attrs: {}, // 用户没用到的props就会放到attrs中
        slots: {}, // 组件都是插槽 ?在初始化组件时会将子组件传入(setupComponent),但是在创建虚拟节点时children为null,这里不是很明白
        setupState: {}, // setup的返回值
        proxy: null,
        emit: null, // 组件通信
        ctx: {}, // 上下文
        isMounted: false, // 组件是否挂载
        subTree: null, // 组件对应的渲染内容
        render: null
    }
    instance.ctx = { _: instance }; // 将自己放到了上下文中,并且在生产环境下不希望用户通过_访问到里边的变量
    return instance;
}
  1. 扩展实例instance的属性

runtime-core/src/renderer.ts

    const mountComponent = (n2, container) => {
        // 1.组件的创建,需要产生一个组件的实例,调用组件实例上的setup方法拿到render函数,再调用render函数,拿到组件对应(要渲染的内容)的虚拟DOM、subTree
        let instance = n2.component = createComponentInstance(n2); // 根据虚拟节点创造一个实例
        // 2.给instance增加属性,调用setup拿到里面的信息
        setupComponent(instance);
    };

runtime-core/src/component.ts

对实例instance的属性进行初始化操作;

export function setupComponent(instance) {
    let { props, children } = instance.vnode;
    // 初始化属性和插槽;暂时简单处理,直接赋值,组件通信再详细写
    instance.props = props;
    instance.slots = children;
    // 在调用实例上的render方法时,可以调用proxy中的属性(按照setupState、ctx、props的顺序查找)
    instance.proxy = new Proxy(instance.ctx, componentPublicInstance);
    // 有setup的就是带状态的组件,目前我们只处理带状态的组件
    setupStatefulComponent(instance);
}

组件的启动,核心就是setup方法;

function setupStatefulComponent(instance) {
    let component = instance.type;
    let { setup } = component;
    if (setup) { // 说明用户提供了setup方法
        let setupContext = createSetupContext(instance);
        // 用户调用setup方法可以拿到传入的属性及当前实例上下文
        let setupResult = setup(instance.props, setupContext);
        handleSetupResult(instance, setupResult);
    } else {
        finishComponentSetup(instance); // 如果用户没写setup,那么直接用外面的render
    }
}
function createSetupContext(instance) { // 根据当前实例获取一个上下文对象
    return {
        attrs: instance.attrs,
        slots: instance.slots,
        emit: instance.emit,
        expose: () => { } // 是为了表示组件暴露了哪些方法,用户可以通过ref调用哪些方法
    }
}
function handleSetupResult(instance, setupResult) {
    if (isObject(setupResult)) { // 用户传入的setup返回了一个对象,将值赋给实例的setupState属性
        instance.setupState = setupResult;
    } else if (isFunction) { // 用户传入的setup返回了一个函数,将值赋给实例的render属性
        instance.render = setupResult;
    }
    // 处理后实例上可能依旧没有render,那么就直接用外面的render
    // 处理后实例依旧没有render的情况:1.用户没写setup;2.用户写了setup但是没有返回值
    finishComponentSetup(instance);
}
function finishComponentSetup(instance) {
    let component = instance.type;
    if (!instance.render) {
        if (!component.render && component.template) {
            // 需要将template变成render函数,compileToFunctions()
        }
        instance.render = component.render;
    }
}

我们在实际Vue3使用中,组件中的render可以拿到属性和setup中的值:

        const App = {
            setup() {
                return { a: 1 }
            },
            render(proxy) {
                // 此处通过代理可以拿到setup返回的结果及根组件提供的属性
                console.log(proxy.name, proxy.age, proxy.a); // zijue 18 1
                // 同时this就是proxy
                console.log(this.name, this.age, this.a); // zijue 18 1
            }
        }
        createApp(App, { name: 'zijue', age: 18 }).mount('#app');

这个是通过在实例instance上添加proxy属性完成的:

    // 在调用实例上的render方法时,可以调用proxy中的属性(按照setupState、ctx、props的顺序查找)
    instance.proxy = new Proxy(instance.ctx, componentPublicInstance);
export const componentPublicInstance = {
    get({ _: instance }, key) {
        const { setupState, props, ctx } = instance;
        if (hasOwnProp(setupState, key)) {
            return setupState[key];
        } else if (hasOwnProp(ctx, key)) {
            return ctx[key];
        } else if (hasOwnProp(props, key)) {
            return props[key];
        }
    },
    set({ _: instance }, key, value) {
        const { setupState, props } = instance;
        if (hasOwnProp(setupState, key)) {
            setupState[key] = value;
        } else if (hasOwnProp(props, key)) {
            props[key] = value;
        }
        return true;
    }
}
  1. 初始化渲染组件effect

保证组件内数据变化时能够重新进行组件的渲染

runtime-core/src/renderer.ts

    const mountComponent = (n2, container) => {
        // 1.组件的创建,需要产生一个组件的实例,调用组件实例上的setup方法拿到render函数,再调用render函数,拿到组件对应(要渲染的内容)的虚拟DOM、subTree
        let instance = n2.component = createComponentInstance(n2); // 根据虚拟节点创造一个实例
        // 2.给instance增加属性,调用setup拿到里面的信息
        setupComponent(instance);
        // 3.调用实例中的render方法;每个组件都有一个effect
        setupRenderEffect(instance, container);
    };
    const setupRenderEffect = (instance, container) => {
        effect(() => { // 每次状态变化后,都会重新执行effect
            if (!instance.isMounted) {
                // console.log('第一次渲染');
                // 组件渲染的内容就是subTree
                let subTree = instance.render.call(instance.proxy, instance.proxy); // 调用render,render需要获取数据
                patch(null, subTree, container); // 渲染子树;即render返回的是h函数创建的虚拟节点:h('div', {}, 'hi, zijue')
                instance.isMounted = true; // 挂载完成
            } else {
                console.log('修改了数据');
            }
        })
    };

组件初始化渲染逻辑最后就是需要去渲染元素subTree,同样是调用patch方法,不同的是走元素渲染逻辑。

元素渲染流程

  • h方法的实现

元素渲染中比较重要的就是h方法,它将创建一个元素的虚拟节点:

runtime-core/src/h.ts

export function h(type, propsOrChildren, children) {
    // h方法第一个参数一定是类型,第二个参数可能是属性可能是儿子,后面的一定都是儿子,没有属性的情况,只能放数组
    // 还有一种情况是可以写文本,一个type + 一个文本
    const l = arguments.length;
    if (l === 2) {
        if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
            if (isVnode(propsOrChildren)) { // h('div', h('p'))
                return createVnode(type, null, [propsOrChildren]);
            } else {
                return createVnode(type, propsOrChildren);
            }
        } else { // 是数组  h('h1', ['hello', 'hello'])
            return createVnode(type, null, propsOrChildren);
        }
    } else {
        if (l > 3) {
            children = Array.from(arguments).slice(2); // 获取第三个及之后的参数
        } else if (l === 3 && isVnode(children)) {
            // children也可能是个文本 或者 是个数组
            children = [children]; // h('div', {}, h('p'));
            // 文本在源码中不用变成数组,因为文本可以直接innerHTML,如果是元素,递归创建
        }
        return createVnode(type, propsOrChildren, children);
    }
}
  • 根据元素虚拟节点创建真实DOM
    const mountElement = (n2, container) => { // 把虚拟节点变成真实的DOM元素
        const { type, props, children, shapeFlag } = n2;
        let el = n2.el = hostCreateElement(type); // 创建对应真实的DOM元素
        if (props) {
            for (let key in props) {
                hostPatchProp(el, key, null, props[key]);
            }
        };
        // 父节点创建完,需要继续创建儿子
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            mountChildren(children, el);
        } else {
            hostSetElementText(el, children);
        };
        // 最后将父节点挂载到container上
        hostInsert(el, container);
    };

处理子节点:

    const mountChildren = (children, container) => {
        for (let i = 0; i < children.length; i++) {
            patch(null, children[i], container);
        }
    };

7.vue3中异步更新策略

组件的异步更新原理

假设,我们写了下面这段代码,在同一个操作中,多次改变依赖的变量,目前我们写的代码,是肯定会多次执行effect操作的。这很明显不符合实际情况的,我们更希望在值的最后一次变化确定后再渲染更新后的页面,这就需要使用组件的异步更新。

        const { createApp, h, reactive, toRefs, ref } = VueRuntimeDOM;
        let counter = 0;
        const App = {
            setup() {
                const state = reactive({ name: 'zijue', age: 18 });
                const flag = ref(true);
                setTimeout(() => {
                    flag.value = false;
                    flag.value = true;
                    flag.value = false;
                    flag.value = true;
                }, 1000);
                return { ...toRefs(state), flag }
            },
            render({ name, age, flag }) { // 每次改变flag的值,都会重新执行
                console.log(counter++);
                return h('li', `${flag.value}`)
            }
        }
        createApp(App).mount('#app');

如何实现对上面代码的优化呢?我们之前在实现ref的原理时,就用到了effect函数中的scheduler参数,组件的异步更新原理同样用到了这个参数,用于数据变化后执行我们定义的更新调度逻辑:

let queue = [];
function queueJob(job) { // 批量处理;多次更新先缓存去重,之后异步更新。这里的job就是更新数据后需要执行的effect
    if (!queue.includes(job)) {
        queue.push(job);
        queueFlush(); // 执行清空任务队列函数,每次都去调用,但是执行器是一个promise,它会等当前同步宏任务执行完成后才执行
    }
}
let isFlushPending = false; // 批处理阻塞
function queueFlush() {
    if (!isFlushPending) { // 保证只执行一次
        isFlushPending = true;
        Promise.resolve().then(flushJobs); // promise会在任务队列添加完成后才执行
    }
}
function flushJobs() {
    isFlushPending = false;
    queue.sort((a, b) => a.id - b.id); // 排序保证组件执行,先父后子
    for (let i = 0; i < queue.length; i++) {
        queue[i]();
    }
    queue.length = 0; // 执行完成后,任务队列需要清空
}
    const setupRenderEffect = (instance, container) => {
        effect(() => { // 每次状态变化后,都会重新执行effect
            ...
        }, {
            scheduler: queueJob
        })
    };

watchAPI

watchAPI的核心就是监控值的变化,值发生变化后调用对应的回调函数

先来看看watchAPI的用法:

        const { watch, reactive, watchEffect } = VueRuntimeDOM;
        const state = reactive({ count: 0 });
        // 同步watch,且立即执行
        watch(() => state.count, function (newValue, oldValue) {
            console.log(newValue, oldValue)
        }, { immediate: true, flush: 'sync' });

        setTimeout(() => {
            state.count++
            state.count++
            state.count++
            state.count++
        }, 1000);

        /* immediate: true, flush: 'sync' 
            0 undefined  // immediate: true表示传入的回调函数一开始就会执行
            1 0
            2 1
            3 2
            4 3
         */

        /* immediate: true, flush: 'post' 
            0 undefined  // immediate: true表示传入的回调函数一开始就会执行
            4 0
         */

        /* flush: 'post' 
            4 0
         */

原理比较简单,实现如下:

let postFlushCbs = [];
function queueJob(job) {
    if (!postFlushCbs.includes(job)) {
        postFlushCbs.push(job);
        queueFlush();
    }
}
let isFlushPending = false;
function queueFlush() {
    if (!isFlushPending) {
        isFlushPending = true;
        Promise.resolve().then(flushPostFlushCbs);
    }
}
function flushPostFlushCbs() {
    isFlushPending = false;
    for (let i = 0; i < postFlushCbs.length; i++) {
        postFlushCbs[i]();
    }
    postFlushCbs.length = 0;
}

function dowatch(source, cb, { immediate, flush }) { // immediate是否立即调用,flush表示怎么刷新(核心属性)
    let oldValue;
    const job = () => {
        if (cb) {
            const newValue = runner(); // 获取新值
            if (hasChanged(newValue, oldValue)) { // 如果值有变化,调用对应的callback
                cb(newValue, oldValue);
                oldValue = newValue; // 更新值
            }
        }
    };
    let scheduler;
    if (flush === 'sync') {
        scheduler = job;
    } else if (flush === 'post') { // 批处理,异步更新,原理同上面的组件异步更新
        scheduler = () => queueJob(job);
    } else {
        // 其它情况
    }
    let runner = effect(() => source(), {
        lazy: true, // 默认不让effect执行
        scheduler
    });
    if (cb) {
        if (immediate) {
            job(); // 手动调用执行一次回调函数cb
        } else {
            oldValue = runner(); // 不让cb立即执行,也需要获取source的返回值赋给oldValue
        }
    }
}
export function watch(source, cb, options) {
    return dowatch(source, cb, options);
}

watchEffect

watchEffect是没有cb的watch,当数据变化后会重新执行source函数

使用方法如下:

        watchEffect(() => {
            console.log(state.count); // 依赖的人变化了 直接就执行 -> effect (有异步更新的逻辑)
        })

watchEffect就是没有cb参数的watch函数,都是通过dowatch函数实现,原理如下:

function dowatch(source, cb, { immediate, flush }) {
    const job = () => {
        if (cb) {
            ...
        } else { // watchEffect不需要新旧对比
            runner();
        }
    };

    if (cb) {
        ...
    } else { // watchEffect默认会执行一次
        runner();
    }
}

export function watchEffect(source, options) {
    return dowatch(source, null, options);
}

2.process 应用

全局变量

  • 概念:全局上可以直接访问的属性
  • 所有模块都可以直接访问到以下 5 个变量,但是它们并不是 global 上的属性
    • __dirname 绝对路径,指代的是当前文件所在的目录
    • __filename 绝对路径,指代的是当前文件的路径
    • exports
    • module
    • require()

global 中比较重要的属性

  • setTimeoutqueueMicrotasksetImmediate
  • process 代表进程,可以获得运行时一些环境和参数
  • Buffer 二进制数据容器,用于处理二进制数据;主要用于文件操作

process 中的重要属性

  • platform 代码运行的平台(win32 => windows,darwin => mac);每个平台找一些用户文件,位置可能不一样
  • cwd 当前工作目录(current working directory),运行时产生的一个路径,指向在哪里执行(可以改变)。相对路径相对的是工作目录,不是当前文件所在的目录。如果是一个确定的路径应使用绝对路径
  • chdir 切换当前工作目录
  • env 默认会读取全局的环境变量(也可以临时设置变量) npm install cross-env -g 全局安装 cross-env 设置临时的代码执行环境变量
  • argv 用户执行时传递的参数;可以使用优秀的命令行管家解析命令行参数 npm install commander
  • process.nextTick

1.webpack介绍

介绍

本质上,webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle

安装

npm install webpack webpack-cli -D

打包入口(entry)

  • 入口起点(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部 依赖图(dependency graph) 的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的;
  • 默认值是 ./src/index.js,但你可以通过在 webpack configuration 中配置 entry 属性,来指定一个(或多个)不同的入口起点。

src/index.js

let name = 'zijue';
console.log(name);

webpack.config.js

const path = require('path');
module.exports = {
    entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
}

输出(output)

  • output属性告诉webpack在哪里输出它所创建的bundle,以及如何命名这些文件;
  • 主要输出文件的默认值为./dist/main.js,其它生成文件默认放置在./dist文件夹中。

webpack.config.js

const path = require('path');
module.exports = {
    entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
+    output: {
+        path: path.resolve(__dirname, 'dist'), // 输出必须写绝对路径
+        filename: 'main.js'
+    }
}

loader

  • webpack默认只能理解JavaScriptJSON文件
  • loaderwebpack能够去处理其它类型的文件,并将他们转换为有效模块,以供应用程序使用,以及被添加到依赖图中。

例如,我们需要导入一个.txt文件,将文本作为字符串;需要先安装raw-loader

npm install --save-dev raw-loader

src/index.js

let name = require('./name.txt');
console.log(name.default);

webpack.config.js

const path = require('path');
module.exports = {
    entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出必须写绝对路径
        filename: 'main.js'
    },
+    module: {
+        rules: [
+            { test: /\.txt$/, use: 'raw-loader' }
+        ]
+    }
}

插件(plugin)

  • loader用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。包括:打包优化、资源管理、注入环境变量等。
  • 以打包html为例,首先安装html-webpack-plugin模块:npm install --save-dev html-webpack-plugin

src/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body></body>
</html>

webpack.config.js

const path = require('path');
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
    entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出必须写绝对路径
        filename: 'main.js'
    },
    module: {
        rules: [
            { test: /\.txt$/, use: 'raw-loader' }
        ]
    },
+    plugins: [
+        new HtmlWebpackPlugin({ template: './src/index.html' })
+    ]
}

打包后,自动将js文件引入到了html文件中;

dist/index.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Document</title>
+    <script defer="defer" src="main.js"></script>
</head>
<body></body>
</html>

模式(mode)

  • webpack 4.x 版本引入了 mode 的概念;
  • mode指定production时,默认会启用各种性能优化的功能,包括构建结果优化以及webpack运行性能优化;
  • mode指定development时,则会开启debug工具,运行时打印详细的错误信息,以及更快加速的增量编译构建。
  • development会将process.env.NODE_ENV的值设为developmentproduction会将process.env.NODE_ENV的值设为production

同下面提到的命令行配置1作用相同

module.exports = {
+    mode: 'development',
    entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出必须写绝对路径
        filename: 'main.js'
    },
    module: {
        rules: [
            { test: /\.txt$/, use: 'raw-loader' }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({ template: './src/index.html' })
    ]
}

环境差异

日常的前端开发工作中,一般都会有两套构建环境。

  • 开发环境
    • 需要生成sourcemap文件
    • 需要打印debug信息
    • 需要live reload或者hot reload的功能
  • 生产环境
    • 可能需要分离CSS成单独的文件,以便多个页面共享同一个CSS文件
    • 需要压缩HTML/CSS/JS代码
    • 需要压缩图片
  • 其默认值为production

区分环境

  • --mode用来设置模块内的process.env.NODE_ENV
  • --env用来设置webpack配置文件的函数参数
  • cross-env用来设置node环境的process.env.NODE_ENV;通过npm i cross-env -D
  • DefinePlugin用来设置模块内的全局变量

命令行配置1

  • webpackmode默认为production
  • webpack servemode默认为development
  • 可以在模块内通过process.env.NODE_ENV获取当前的环境变量,无法在webpack配置文件中获取此变量

package.json

  "scripts": {
    "build": "webpack",
    "dev": "webpack serve"
  },

src/index.js

console.log(process.env.NODE_ENV);  // build => production | dev => development

webpack.config.js

console.log(process.env.NODE_ENV);  // undefined

命令行配置2

命令行配置1效果相同

package.json

  "scripts": {
    "build": "webpack --mode=production",
    "dev": "webpack serve --mode=development"
  },

命令行配置3

  • 无法在模块内通过process.env.NODE_ENV访问
  • 可以通过webpack.config.js中通过函数获取当前环境变量

package.json

  "scripts": {
    "build": "webpack --env=production",
    "dev": "webpack serve  --env=development"
  },

src/index.js

console.log(process.env.NODE_ENV);  // undefined

webpack.config.js

console.log(process.env.NODE_ENV);  // undefined
module.exports = (env, argv) => {
    console.log(env); // { ..., production: true } | { ..., development: true }
    return {
        mode: 'development',
        entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
        output: {
            path: path.resolve(__dirname, 'dist'), // 输出必须写绝对路径
            filename: 'main.js'
        },
        module: {
            rules: [
                { test: /\.txt$/, use: 'raw-loader' }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({ template: './src/index.html' })
        ]
    }
}

DefinePlugin

  • 设置全局变量(不是window),所有模块都能读取到该变量的值
  • 可以在任意模块内通过process.env.NODE_ENV获取当前的环境变量
  • 但无法在node环境(webpack 配置文件中)下获取当前的环境变量

webpack.config.js

module.exports = {
    entry: path.resolve(__dirname, 'src/index.js'), // 此处可以写相对路径,默认值为:./src/index.js
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出必须写绝对路径
        filename: 'main.js'
    },
    module: {
        rules: [
            { test: /\.txt$/, use: 'raw-loader' }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({ template: './src/index.html' }),
+        new webpack.DefinePlugin({
+            'process.env.NODE_ENV': JSON.stringify('development'),
+            'NODE_ENV': JSON.stringify('production')
+        })
    ]
}

src/index.js

console.log(process.env.NODE_ENV);  // development
console.log(NODE_ENV);  // production

webpack.config.js

console.log(process.env.NODE_ENV);  // undefined
console.log('NODE_ENV',NODE_ENV); // error报错

cross-env

  • 只能设置node环境(webpack 配置文件中)下的变量NODE_ENV;使用前需要先安装:npm i cross-env -D

package.json

"scripts": {
  "build": "cross-env NODE_ENV=development webpack"
}

webpack.config.js

console.log('process.env.NODE_ENV', process.env.NODE_ENV); // development

puppeteer 截图,高德地图显示不全问题

环境

  • puppeteer: 13.5.1
  • chromium: 970485

问题描述

在本地linux虚拟机中使用puppeteer截图网页DOM时,高德地图部分未渲染(区域显示为空);但在宿主机windows上运行同样的代码进行截图,高德地图部分全部加载

  • linux虚拟机中截图
  • windows宿主机截图

排查过程

  • 起初认为是linux为无GUI的,无法像桌面端那样调用GPU资源从而无法使用webgl。遂进行如下测试:
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({
    // headless: false,
  });
  const page = await browser.newPage();

  // Test for webgl support
  // e.g. https://developer.mozilla.org/en-US/docs/Learn/WebGL/By_example/Detect_WebGL
  const webgl = await page.evaluate(() => {
    const canvas = document.createElement('canvas');
    const gl = canvas.getContext('webgl');
    const expGl = canvas.getContext('experimental-webgl');

    return {
      gl: gl && gl instanceof WebGLRenderingContext,
      expGl: expGl && expGl instanceof WebGLRenderingContext,
    };
  });

  console.log('WebGL Support:', webgl);

  await browser.close();
})();
  • 输出结果,桌面端windows与虚拟机linux结果输出一致
WebGL Support: { gl: true, expGl: true }
  • 查看虚拟机linuxgpu支持情况
./node_modules/puppeteer/.local-chromium/linux-970485/chrome-linux/chrome --headless --no-sandbox --print-to-pdf chrome://gpu
  • 通过尝试,发现地图是可以正常导出的,例如:
您好,JSAPI 2.0 版本依赖 WebGL环境,需要同时满足以下条件:
1、浏览器支持WebGL;
2、Chrome 在有些环境下硬件加速是关闭的,需要开启硬件加速;
3、Chrome 有一个内置的显卡黑名单,黑名单内的显卡无法获取到webgl上下文,此时会Chrome启动CPU模拟显卡(google swiftshader),页面会非常卡顿,可以尝试在 chrome://flags/#ignore-gpu-blocklist 打开 "Override software rendering list" 特性;
4、JSAPI 默认启用了 **failIfMajorPerformanceCaveat** 参数来获取 webgl 上下文,图形绘制性能比较差的环境下不开启WebGL绘制,如需要可以在 地图 JSAPI 脚本引用之前设置全局变量 window.forceWebGL = true。
辛苦您排查下。2.0全局变量也可以使用增强版:window['forceWebGLBaseRender'] = true
  • 立刻意识到,可能是第四点导致。添加全局变量后,虚拟机linux导出也正常了

感谢

2.vue3响应式原理

Vue3中reactivity模块的用法

将需要变成响应式的数据通过reactive, shallowReactive, readonly, shallowReadonly中任一方法包装后返回proxy对象,当包装后的proxy对象在effect中取值时会依赖收集,当赋值时,会重新执行effect

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div id="app"></div>
    <script src="node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
    <script>
        let { reactive, shallowReactive, readonly, shallowReadonly, effect } = VueReactivity;
        let zijue = { name: 'zijue', age: 18, address: { city: 'wh' } };
        let proxy = reactive(zijue); // 包装返回proxy对象,当在effect中取值时会进行依赖收集,当赋值时,会重新执行effect
        // effect 会默认执行,执行时会手机属性的依赖;watch computed都是基于这个effect来实现的
        effect(()=>{
            app.innerHTML = proxy.name + ': ' + proxy.age + ': ' + proxy.address.city;
        });
        setTimeout(()=>{
            proxy.name = 'xiaochi'; // 一秒后修改name属性
        }, 1000);
        setTimeout(()=>{
            proxy.address.city = 'xg'; // 两秒后修改address.city属性
        }, 2000);
    </script>
</body>

</html>

上面展示的是reactive的使用效果,四个响应式方法区别如下:

  • reactive:会将对象里的所有对象属性都进行进行代理
  • shallowReactive:只代理第一层对象
  • readonly:会代理对象,但是属性不能修改,同时不进行依赖收集节约性能
  • shallowReadonly:因为外层没有收集依赖,虽然内层能改但是不会更新视图

Vue3响应式原理的实现

响应式模块结构的初步搭建

目录结构如下:

packages                    # 模块根路径
├── reactivity              # 响应式模块
│   ├── package.json
│   └── src
│       ├── handlers.ts     # 存放proxy具体处理逻辑
│       ├── index.ts        # 包入口
│       └── reactive.ts     # 存放响应式方法
└── shared                  # 共享方法模块
    ├── package.json
    └── src
        └── index.ts        # 包入口

packages/reactivity/src/index.ts

export * from './reactive';

packages/reactivity/src/reactive.ts

import { isObject } from '@vue/shared';
import { mutableHandler, readonlyHandlers, shallowReactiveHandlers, shallowReadonlyHanlders } from "./handlers";

export function reactive(target){
    return createReactiveObject(target, false, mutableHandler);
}
export function shallowReactive(target){
    return createReactiveObject(target, false, shallowReactiveHandlers);
}
export function readonly(target){
    return createReactiveObject(target, true, readonlyHandlers);
}
export function shallowReadonly(target){
    return createReactiveObject(target, true, shallowReadonlyHanlders);
}

function createReactiveObject(target, isReadonly, baseHandler){
    /**
     * target  创建代理的目标
     * isReadonly  是否为只读
     * baseHandler  针对不同的方式创建不同的代理对象
     */
    if(!isObject(target)){
        return target;
    }
    // 如果是对象,就做代理 new Proxy
    let proxy = new Proxy(target, baseHandler);
    return proxy;
}

其中import { isObject } from '@vue/shared';跨模块引入会出现如下错误:

按照提示,需要在tsconfig.json中配置:

    "moduleResolution": "node",                 
    "baseUrl": "./",
    "paths": {
      "@vue/*": [
        "packages/*/src"
      ]
    },

packages/reactivity/src/handlers.ts

// 此文件负责proxy中get、set的具体实现
export const mutableHandler = {

}
export const shallowReactiveHandlers = {
    
}
export const readonlyHandlers = {
    
}
export const shallowReadonlyHanlders = {
    
}

对同一对象多次调用reactive的处理

let { reactive, shallowReactive, readonly, shallowReadonly, effect } = VueReactivity;
let zijue = { name: 'zijue', age: 18, address: { city: 'wh' } };
let proxy = reactive(zijue);
let proxy = reactive(zijue); // 多次调用

如上面这段代码,当对同一对象多次调用响应式函数时,我们希望只做一次代理,后续调用,直接走缓存查找并返回。
那么如何处理呢?答案是:weakMap

为什么不用map而用weakMap

  • weakMap:key 只能是对象;weakMap是弱引用,如果对象key被销毁,weakMap可以自动释放掉
  • map:key 可以是其它类型

通过查看浏览器内存快照查看两者区别:

function User() { }
let user = new User();
let map = new Map();
map.set(user, 1);
user = null;
function User() { }
let user = new User();
let map = new WeakMap();
map.set(user, 1);
user = null;

所以最终处理如下:

// 添加缓存
const reactiveMap = new WeakMap();
const readonlyMap = new WeakMap(); // reactive与readonly代理对象的结果是不一样的,所以需要将两者分别缓存

function createReactiveObject(target, isReadonly, baseHandler) {
    if (!isObject(target)) {
        return target;
    }
    let proxyMap = isReadonly ? readonlyMap : reactiveMap;
    let existProxy = proxyMap.get(target);
    if (existProxy) {
        return existProxy;
    }
    // 如果是对象,就做代理 new Proxy
    let proxy = new Proxy(target, baseHandler);
    proxyMap.set(target, proxy);
    return proxy;
}

完善proxy中的get、set的生成

proxy处理器handler最终就是为了实现不同get与set方法(是否只读、是否浅代理),故将get与set的创建提取出来封装:

function createGetter(isReadonly = false, isShallow = false) {
    return function get(target, key, receiver) {
        /**
         * target   代理的源对象
         * key      取值的属性
         * receiver 代理对象
         */
        console.log('proxy getter');
    }
}
function createSetter(isShallow = false) {
    return function set(target, key, value, receiver) {
        console.log('proxy setter');
    }
}

const get = createGetter(); // 非只读,非浅代理
const shallowGet = createGetter(false, true); // 非只读,浅代理
const readonlyGet = createGetter(true); // 只读,非浅代理
const shallowReadonlyGet = createGetter(true, true); // 只读,浅代理
// readonly只读没有set
const set = createSetter(); // 非浅代理
const shallowSet = createSetter(true); // 浅代理

export const mutableHandler = {
    get,
    set
}
export const shallowReactiveHandlers = {
    get: shallowGet,
    set: shallowSet
}
const readonlySet = {
    set(target, key, value, receiver) {
        throw Error(`Proxy '${JSON.stringify(target)}' that property '${key}' is a read-only, cannot set '${key}' to '${value}'.`);
    }
}
/** Object.assign 方法用于将所有可枚举属性的值从一个或多个源对象分配到目标对象。它将返回目标对象
 * const target = { a: 1, b: 2 };
 * const source = { b: 4, c: 5 };
 * const returnedTarget = Object.assign(target, source);
 * console.log(target); // expected output: Object { a: 1, b: 4, c: 5 }
 * console.log(returnedTarget); // expected output: Object { a: 1, b: 4, c: 5 }
 */
const extend = Object.assign; // 可以作为公用方法放入shared模块中
export const readonlyHandlers = extend({
    get: readonlyGet
}, readonlySet);
export const shallowReadonlyHanlders = extend({
    get: shallowReadonlyGet
}, readonlySet);

补充get方法的处理逻辑

在完善get方法之前,我们先介绍一个API:Reflect。是用于在proxy对象中操作的最佳拍档,通过下面一个例子进行说明:

例子原blog地址

const target = {
    get foo() {
        return this.bar;
    },
    bar: 3
};
const handler = {
    get(target, propertyKey, receiver) {
        if (propertyKey === 'bar') return 2;
        console.log('Reflect.get ', Reflect.get(target, propertyKey, receiver)); // this in foo getter references Proxy instance; logs 2
        console.log('target[propertyKey] ', target[propertyKey]); // this in foo getter references "target" - logs 3
    }
};
const obj = new Proxy(target, handler);
console.log(obj.bar);
// 2
obj.foo;
// Reflect.get  2
// target[propertyKey]  3

所以为了正确的代理原对象,我们需要在proxy中使用Reflect

取值部分的get逻辑如下:

function createGetter(isReadonly = false, isShallow = false) {
    return function get(target, key, receiver) {
        /**
         * target   代理的源对象
         * key      取值的属性
         * receiver 代理对象
         */
        // 一般使用Proxy会配合Reflect使用
        const res = Reflect.get(target, key, receiver);
        if (!isReadonly) { // 不是只读属性,收集此属性用于之后值变化时更新视图
            console.log('收集当前属性,之后属性值改变,更新视图', key);
        }
        if (isShallow) { // 浅代理,只代理第一层属性,更深层次不做处理
            return res;
        }
        if (isObject(res)) { // 懒代理;当我们取值时才去做递归代理,如果不取值默认只代理一层
            return isReadonly ? readonly(res) : reactive(res);
        }
        return res;
    }
}

补充set方法的处理逻辑

set方法主要是需要知道是增加值还是修改值,便于后续逻辑的执行;同时处理好数组新增带来的length属性修改的二次触发问题。代码如下:

function createSetter(isShallow = false) {
    /** 针对数组而言,如果调用push方法,就会产生两次触发
     * 1.第一次给数组新增了一项,同时也修改了长度
     * 2.因为修改了长度,所以第二次触发set(此次触发是无意义的)
     */
    return function set(target, key, value, receiver) {
        // 设置属性:新增、修改
        const oldValue = Reflect.get(target, key, receiver); // 获取老值
        /** 如何判断数组是新增还是修改
         * key是数字 && key < target.length => 新增
         * 否则就是修改
         */
        let hasKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwnProp(target, key);
        const res = Reflect.set(target, key, value, receiver); // 必须先判断是否有key,再更改值
        if (!hasKey) {
            console.log('新增');
        } else if (hasChanged(oldValue, value)) {
            console.log('修改');
        } else {
            console.log('无变化'); // 数组第二次触发会在此处,无意义,所以此处不添加逻辑
        }
        return res;
    }
}

effect函数的实现

先来看看effect函数的使用效果:

let zijue = { name: 'zijue', age: 18, address: { city: 'wh' }, arr: [1, 2, 3] };
let proxy = reactive(zijue);
effect(() => { // 1.默认effect中函数会执行一次,执行的时候应该把用到的属性和这个effect关联起来
    console.log(proxy.name);
}); // 2.下次更新属性的时候,会再次执行这个effect

那么初步搭建一下effect函数的结构,effect肯定是一个高级函数,返回一个响应式的effect,同时可以通过参数控制默认是否执行的行为。如下:

export function effect(fn, options: any = {}) {
    const effect = createReactiveEffect(fn, options);
    if (!options.lazy) {
        effect();
    }
    return effect;
}

function createReactiveEffect(fn, options) {
    const effect = function reactiveEffect() {
        console.log('effect');
    }
    return effect;
}

初步结构构建后,首选需要解决effect嵌套的问题:

effect(() => {
    console.log(proxy.name);
    effect(() => {
        console.log(proxy.age);
    });
    console.log(proxy.address);
})

对于上面这段代码,我们肯定是希望proxy.nameproxy.address收集的是外层的effect,而proxy.age收集的是里层的effect。不难想到这种类似于函数调用的形式,最好就是用来维护属性与effect的关系。

export function effect(fn, options: any = {}) {
    const effect = createReactiveEffect(fn, options);
    if (!options.lazy) {
        effect();
    }
    return effect;
}
let activeEffect; // 当前调用的effect
const effectStack = [];
let id = 0;
function createReactiveEffect(fn, options) {
    const effect = function reactiveEffect() {
        try {
            effectStack.push(effect);
            activeEffect = effect;
            return fn();
        } finally { // 返回值后最终也会执行的逻辑
            effectStack.pop();
            activeEffect = effectStack[effectStack.length - 1];
        }
    }
    effect.id = id++;
    return effect;
}

依赖收集的原理

目前,effect中传入的函数执行时,会去取值,但是当修改值,没有办法再次执行effect重新渲染视图,所以需要在取值时做依赖收集。

需要实现的逻辑是:

  1. 当用户取值的时候,需要将activeEffect和属性做关联;
  2. 当用户更改属性值的时候,要通过属性找到effect从新执行。

我们需要两个函数:一个依赖收集函数track,一个触发effect函数trigger

reactivity/src/effects.ts

// 一个属性对应多个effect,一个effect对应多个属性 ==> 多对多的关系
const targetMap = new WeakMap();
export function track(target, action, key) {
    /**
    targetMap = WeakMap{
        target: Map{
            key: Set(Effect1, Effect2)
        }
    }
     */
    if (activeEffect == undefined) {
        return; // 用户只是取值,而且这个值不是在effect中使用的,什么都不用收集
    }
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
        depsMap.set(key, (dep = new Set()));
    }
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
    }
}
export function trigger(target, action, key, newValue, oldValue?) {
    // 去映射表中找到属性对应的effect,让其重新执行
    const depsMap = targetMap.get(target);
    if (!depsMap) return; // 只是改了属性,这个属性没有在effect中使用
    const effectSet = new Set();
    const add = (effects) => { // 如果同时有多个属性依赖的effect是同一个,new Set()会去重
        if (effects) {
            effects.forEach(effect => effectSet.add(effect));
        }
    }
    add(depsMap.get(key)); // 将属性收集effects添加到统一的集合中
    effectSet.forEach((effect: any) => effect()); // 遍历所有收集的effect并执行
}

reactivity/src/handlers.ts

function createGetter(isReadonly = false, isShallow = false) {
    return function get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver);
        if (!isReadonly) {
            track(target, 'get', key); // 依赖收集
        }
        if (isShallow) {
            return res;
        }
        if (isObject(res)) {
            return isReadonly ? readonly(res) : reactive(res);
        }
        return res;
    }
}
function createSetter(isShallow = false) {
    return function set(target, key, value, receiver) {
        let hasKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwnProp(target, key);
        const res = Reflect.set(target, key, value, receiver);
        if (!hasKey) {
            trigger(target, 'add', key, value, oldValue); // 新增触发
        } else if (hasChanged(oldValue, value)) {
            trigger(target, 'set', key, value, oldValue); // 修改触发
        }

        return res;
    }
}

修复数组更新方法的bug

需要说明的是,对数组的使用需要JSON.stringify(proxy.arr)包裹,当调用JSON.stringify( )的时候,会访问数组中每一个属性,包括length。否则proxy.arr这样只是对arr属性进行了收集,arr是一个数组(引用类型),修改arr的值就不会触发此effect重新执行。

目前我们写的代码对于数组的操作还有些bug:

  • 修改数组的length未触发effect重新执行
        effect(()=>{
            console.log(proxy.arr[2]);
        });
        proxy.arr.length = 1; // 未收集length属性,修改length一样需要触发

如上述代码:只对数组下标2属性做了依赖收集,未对length属性依赖收集,所以当修改length属性已经影响到数组的取值时依旧不会重新执行effect。对于这种情况,我们需要去手动触发:

reactivity/src/effects.ts

export function trigger(target, action, key, newValue, oldValue?) {
    // 去映射表中找到属性对应的effect,让其重新执行
    const depsMap = targetMap.get(target);
    if (!depsMap) return; // 只是改了属性,这个属性没有在effect中使用
    const effectSet = new Set();
    const add = (effects) => { // 如果同时有多个属性依赖的effect是同一个,new Set()会去重
        if (effects) {
            effects.forEach(effect => effectSet.add(effect));
        }
    }
    if (key === 'length' && isArray(target)) { // 当修改的是数组的length属性时,需要视情况触发更新
        depsMap.forEach((deps, key) => {
            if (key > newValue || key === 'length') { // 此处的key是收集依赖的属性
                add(deps); // 当收集的依赖的属性 < 更改的数组长度时,需要手动触发
            }
        });
    } else {
        add(depsMap.get(key)); // 将属性收集effects添加到统一的集合中
    }
    effectSet.forEach((effect: any) => effect()); // 遍历所有收集的effect并执行
}
  • 调用数组push方法时未触发effect

未触发的原因:当数组push值时,走trigger(target, 'add', key, value)新增触发逻辑,key为Int且数组未收集;所以此处也需要手动更新。

reactivity/src/effects.ts

    if (key === 'length' && isArray(target)) { // 当修改的是数组的length属性时,需要视情况触发更新
        depsMap.forEach((deps, key) => {
            if (key > newValue || key === 'length') { // 此处的key是收集依赖的属性
                add(deps); // 当收集的依赖的属性 < 更改的数组长度时,需要手动触发
            }
        });
    } else {
        add(depsMap.get(key)); // 将属性收集effects添加到统一的集合中
        // 当数组push值时,走trigger(target, 'add', key, value)新增触发逻辑,key为Int且数组未收集;所以此处也需要手动更新
        switch (action) {
            case 'add':
                if (isArray(target) && isIntegerKey(key)) {
                    add(depsMap.get('length')); // 增加属性,需要触发length的依赖收集
                }
        }
    }

源码地址

3.ref与计算属性computed

ref的实现原理

reactive等响应式API只能处理对象或者是数组。如果我们希望将普通值也变成响应式的,这就需要ref函数了。原理是通过将普通值变成一个引用类型(借助类的属性访问器实现),让普通值也具有响应式的能力。实现如下:

export function ref(value) { // 可以传入对象
    // 把普通值变成一个引用类型,让一个普通值也具备响应式的能力
    return createRef(value);
}
export function shallowRef(value) {
    return createRef(value, true);
}
const convert = (v) => isObject(v) ? reactive(v) : v;
class RefImpl {
    public _value;
    public __v_isRef = true; // 表示它是一个ref
    constructor(public rawValue, public shallow) {
        this._value = shallow ? rawValue : convert(rawValue);
    }
    get value() {
        track(this, 'get', 'value'); // 收集依赖
        return this._value;
    }
    set value(newValue) {
        if (hasChanged(this.rawValue, newValue)) {
            this.rawValue = newValue; // 用于下次对比
            this._value = this.shallow ? newValue : convert(newValue);
            trigger(this, 'set', 'value', newValue); // 触发依赖
        }
    }
}
function createRef(value, shallow = false) {
    return new RefImpl(value, shallow); // ref实现需要借助类的属性访问器
}

toReftoRefs

这两个API都是将响应式代理对象中的某个值单独拿出来用,并且保持它也是一个响应式的。使用如下:

        let { reactive, effect, toRef, toRefs } = VueReactivity;
        let proxy = reactive({ name: 'zijue', age: 18 });
        // 如果我们希望将reactive中的某个值单独拿出来用,并且希望它是一个响应式的,这个时候就可以使用toRef api
        let nameRef = toRef(proxy, 'name');
        effect(() => {
            console.log(nameRef.value); // zijue ==> 1s 后 ==> xiaochi
        });
        setTimeout(() => {
            nameRef.value = 'xiaochi';
        }, 1000)

        // 为了更加方便的使用reactive中的属性,toRefs可以批量转化
        let { name, age } = toRefs(proxy);
        console.log(name, age); // name,age均转化为了ref ObjectRefImpl {target: Proxy, key: "name", __v_isRef: true} {target: Proxy, key: "age", __v_isRef: true}

toRef就是通过类的属性访问器对proxy属性进行操作,实现起来比较的简单。原理代码如下:

class ObjectRefImpl {
    public __v_isRef = true;
    constructor(public target, public key) { }
    get value() {
        return this.target[this.key];
    }
    set value(newValue) {
        this.target[this.key] = newValue;
    }
}
export function toRef(target, key) {
    return new ObjectRefImpl(target, key);
}
export function toRefs(target) {
    const res = isArray(target) ? new Array(target.length) : {};
    for (let key in target) {
        res[key] = toRef(target, key);
    }
    return res;
}

computed计算属性

首先观察一下computed执行效果,如下:

        let { reactive, effect, computed } = VueReactivity;
        let proxy = reactive({ name: 'zijue', age: 18 });
        // 把年龄 + 1
        let newAge = computed(() => { // 计算属性也是一个effect,age会收集计算属性的effect
            console.log('runner');
            return proxy.age + 1;
        })
        console.log(newAge.value); // 不取值computed不会执行
        proxy.age = 20; // 更改age的值后,还需要计算属性重新取值才会去执行计算属性

        effect(()=>{
            console.log(newAge.value); // 计算属性也具有收集功能,可以收集effect
        });
        proxy.age = 30; // 更新age => 触发计算属性收集的effect => 计算属性取值 => 触发age收集的effect

        // 上述代码中:age会收集computed,computed会收集它所在的effect
        // 当更新age时,会触发age收集的computed effect,还会触发计算属性收集的effect

通过观察发现,computed是一个懒执行的effect,并且计算属性不取值就不会执行。同时也利用了类的属性访问器,于是初步可以编写如下代码:

reactivity/src/computed.ts

class ComputedRefImpl {
    public effect;
    public _value;
    constructor(public getter, public setter) {
        // computed本身就是一个effect,并且是懒执行的
        this.effect = effect(getter, { lazy: true });
    }
    // 如果用户不去计算属性中取值,就不会执行计算属性的effect
    get value() {
        this._value = this.effect(); // .value时执行effect,返回值就是计算后的属性值
        return this._value;
    }
    set value(newValue) {
        this.setter(newValue);
    }
}

export function computed(getterOrOptions) {
    let getter;
    let setter;

    if (isObject(getterOrOptions)) {
        getter = getterOrOptions.get;
        setter = getterOrOptions.set;
    } else {
        getter = getterOrOptions;
        setter = () => {
            throw Error('Computed cannot set value')
        }
    }
    return new ComputedRefImpl(getter, setter);
}

为了避免计算属性多次.value取值时,每次都去执行影响性能,我们可以设置一个属性public _dirty = true;,值为true时,表示该计算属性的值是脏数据,需要重新执行effect取值,否则直接返回this._value

    get value() {
        if (this._dirty) {
            this._value = this.effect(); // .value时执行effect,返回值就是计算后的属性值
            this._dirty = false; // 取完值后,将计算属性设为干净的,避免.value每次都去执行effect影响性能
        }
        return this._value;
    }

这样就产生了两个问题,当源属性改变时,会重新执行计算属性effect,而且不会修改this._dirty。我们需要的是当我们.value取值时才去执行计算属性effect,同时当源属性值发生变化之后,应该将this._dirty设置为false,而不是重新执行计算属性effect。所以需要修改effect函数,添加scheduler参数,当effect函数传入scheduler调度器时,应该走调度器逻辑,而不是默认的effect执行逻辑。

reactivity/src/computed.ts

    constructor(public getter, public setter) {
        this.effect = effect(getter, {
            lazy: true,
            scheduler: (effect) => {
                // 自己实现触发调度逻辑
                if (!this._dirty) {
                    this._dirty = true;
                }
            }
        });
    }

reactivity/src/effect.ts > trigger

export function trigger(target, action, key, newValue, oldValue?) {
    ...
    effectSet.forEach((effect: any) => {
        // 数据变化时,原则上应该触发对应的effect让他重新执行,如果effect提供了scheduler,那么就让scheduler执行,而不是effect重新执行
        if (effect.options.scheduler) {
            effect.options.scheduler(effect);
        } else {
            effect();
        }
    }); // 遍历所有收集的effect并执行
}

最后还有一点问题就是,计算属性也需要收集依赖:

reactivity/src/computed.ts

class ComputedRefImpl {
    public effect;
    public _value;
    public _dirty = true;
    constructor(public getter, public setter) {
        // computed本身就是一个effect,并且是懒执行的
        this.effect = effect(getter, {
            lazy: true,
            scheduler: (effect) => {
                // 自己实现触发调度逻辑
                if (!this._dirty) {
                    this._dirty = true;
                    trigger(this, 'get', 'value'); // 当计算属性依赖的属性改变时,触发计算属性收集的依赖
                }
            }
        });
    }
    // 如果用户不去计算属性中取值,就不会执行计算属性的effect
    get value() {
        if (this._dirty) {
            this._value = this.effect(); // .value时执行effect,返回值就是计算后的属性值
            this._dirty = false; // 取完值后,将计算属性设为干净的,避免.value每次都去执行effect影响性能
        }
        track(this, 'get', 'value'); // 计算属性也要收集依赖
        return this._value;
    }
    set value(newValue) {
        this.setter(newValue);
    }
}

源码地址

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.