zijue / blog Goto Github PK
View Code? Open in Web Editor NEWpersonal knowledge collection
personal knowledge collection
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函数不同于普通函数的另一个地方,即执行它不会返回结果,而是返回的是指针对象。每次调用指针it
的next
方法,会返回一个对象,表示当前阶段的信息(value和done)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示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的原理,可以先看看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
中提供的wrap
、mark
方法实现generator功能,其中mark
方法只起一个标记功能,没有什么实际功能。下面我们来完成这两个方法:
wrap
函数返回了一个指针对象,该指针对象是可以调用next
方法的,next
方法可以接收值,同时返回包含value
和done
属性的对象。所以我们先搭建一个框架:
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;
}
}
文件目录树如下所示:
.
├── 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.js
和 package.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 环境的可执行脚本
/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 可执行文件
nrm use 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 多了一个下载功能,用完及删除
cookie、session、jwt都是为了解决http无状态下,服务器对客户端的身份识别设计的。它们的特点如下:
redis
数据库存储session;我们使用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/write
,F12
查看cookie:
如上图所以,我们成功设置了cookie。其中domain
字段可以设置子域名获取父域名的cookie。例如:
/etc/hosts
文件,添加两条127.0.0.1
的自定义域名映射127.0.0.1 a.test.zijue.cn
127.0.0.1 b.test.zijue.cn
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.cn
与b.test.zijue.cn
域名不同,但是b.test.zijue.cn
是.test.zijue.cn
的子域,所以可以拿到age
字段的cookie。
cookie中httpOnly
字段设置为true表示只能由浏览器获取,不能通过js脚本获得(document.cookie);
cookie中exipres/max-age
字段表示cookie的存活时间,默认为session
(浏览器关闭就销毁)。
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 算法),所以我们需要进行处理。
虽然我们对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
,需要通过安装第三方包实现。
使用session虽然安全性得到了保障,但是扩展性不行。当代码运行在服务器集群上时,session共享就是一个问题,而且因为session基于cookie,无法跨域,所以解决单点登录问题也很麻烦。为了解决这些问题,我们有一种更好的方式JWT
。服务器不保存数据,所有数据都保存在客户端,每次请求都发回服务器。
{
"name": "zijue",
"role": "admin",
"expires": "xxxx-xx-xx"
}
以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名(详见后文)。
Header
(头部)、Payload
(负载)、Signature
(签名)三部分通过.
连接组成的字符串Header.Payload.Signature
,实际样式如下:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
{
"typ": "JWT",
"alg": "HS256"
}
alg
属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ
属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串;
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是可以反解的,所以不要把敏感信息放到此处。
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')
});
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,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
├── 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
对象,该对象上拥有request
、response
封装对象作为属性;
// 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
;
request
和response
对象// 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;
request
和response
对象上的方法和属性代理到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()
时,前面必须加await
或return
,这样才能保证后面的中间件执行完成。
对于使用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);
}
假设有这样一个需求,需要先读取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
方法(成功和失败)中返回一个promise
,promise
会采用返回的promise
的成功的值或失败的原因,传递到外层的下一次then
方法中。
链式调用的特点:
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
接收onFulfilled
或onRejected
的返回值,并将x
与promise2
传入Promise处理函数中;并且在onFulfilled
或onRejected
执行出错时,将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
的成功结果进行返回只能适用于处理普通值,如果onFulfilled
或onRejected
返回的是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
函数主要是为了处理x
,x
的值有两种情况: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中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.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
在
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
假设现在有两个文件a.txt
、b.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();
})
}
koa
、express
都是基于这个co
写法的。
TypeScript 是 JavaScript 的超集,主要提供了类型系统和对 ES6 的支持,它由 Microsoft 开发,代码开源在 github 上。
全局安装 TypeScript 对 TS 进行编译
npm install -g typescript
安装完成后,我们就可以在任意位置执行 tsc
命令
tsc --init # 生成 tsconfig.json
tsc helloworld.ts # 将 .ts 文件编译成 js 文件
在实际项目开发中,不可能每次都去调用 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
npx tsc --init
新建 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
})
]
}
"scripts": {
"dev": "rollup -c -w"
},
src/index.ts
和 public/index.html
文件,在 index.html
文件中引入 /dist/bundle.js
npm run dev
启动项目 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
})
};
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
*/
最近在利用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
headless
与 non-headless
模式下默认字体不一致await page.evaluateHandle('document.fonts.ready');
await page.reload({ waitUntil: ["networkidle0", "domcontentloaded"] });
当依赖的属性变化时,会重新执行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.新旧子节点都是文本
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 { // 新子节点是数组
...
}
};
将老子节点的父节点清空,然后挂载所有新子节点;
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); // 然后挂载新的子节点
}
}
};
对于双方都是数组的情况,我们首先需要了解两个对比的方法:
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--;
}
};
根据上面两种方法比对新老子节点数组,有以下几种情况:
common sequence + mount
:同序列挂载;新节点在不破坏老节点顺序的基础上增加新的节点;可以发现,不管是从头部插入还是从尾部添加新的元素,i
和e1
的关系始终满足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);
}
}
}
common sequence + unmount
:同序列卸载;新节点在不破坏老节点顺序的基础上减少节点;同样可以发现,不管是在头部删除,还是尾部删除元素,i
和e2
的关系始终满足i > e2
;
if (i > e1) {
...
} else if (i > e2) { // common sequence + unmount 老的多,新的少
while (i <= e1) { // 表示有需要删除的部分
unmount(c1[i++]);
}
}
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 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
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;
}
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
模块本质上是封装,把模块内可访问的和不可以访问的区分得清清楚楚。同时为了解决冲突,实现高内聚低耦合
node 遵循了 CommonJS 的模块规范来隔离每个模块的作用域。与 ES6 模块规范对比
module.exports
导出需要给别人使用的值require
拿到需要的结果下面介绍三个内置模块便于后面学习 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 创建的函数只能在全局作用域中运行
点击 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"
}
]
}
通过调试源码梳理 require 模块引入代码执行顺序如下
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
核心原理流程
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,直接手动赋值
}
}
在源码 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}
时取值。
这次我们使用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 + generator
。async+await
就是语法糖,本质上就是co
库和generator
,写起来像同步方法,但是内部还是递归调用异步方法。
puppeteer
: 13.5.1
chromium
: 970485
headless
模式下,puppeteer
截图Win
或Linux
平台展现效果不同;通过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']
});
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 模块,通过手写
on
、emit
、off
、once
实现原理彻底搞清楚
首先新建 events.js
、demo.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 语法
// 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 是 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 站
*/
基础学习参考以下两篇文章
http特点
http
是不保存状态的协议,使用cookie
来管理状态tcp
连接建立和断开,所以采用keep-alive
的方式保持连接http
请求采用管线化的方式(可以并发请求,例js
、css
资源并发请求;同一域名下最大并发请求数为6)静态资源加载可以使用
cdn
的方式增加请求的并发数--域名分割技术
http缺点
通过SSL(安全套阶层)建立安全通信线路 HTTPS (超文本传输安全协议)
option请求方法
options
跨域用的(默认先访问一次预检请求,能访问再发送真正的请求)get
和post
,如果在这两个请求的基础上增加自定义的header
会变成复杂请求URI、URL与URN的区别
uri
统一资源标识符;标识一个独一无二的资源(某人的身份证号)url
统一资源定位符;用地址定位一个资源(某人的家庭住址 --> 通过位置定位资源)urn
统一资源命名符;用名称定位一个资源(某人的身份证号 --> 通过身份证号表示某人,不通过某人的位置所在)。即通过名称来标识资源,不依赖于位置,并且有可能减少失效链接个个数举个例子:
寻找某个具体的人,如果通过家庭地址找(xx省xx市xx区 ... xx单元xx室),这就是
url
(通过地址定位资源);如果通过身份证号去找就是urn
(不通过某人所在的位置,而是通过特定规则的名称标识资源)
事件环是 node 处理非阻塞 I/O 操作的机制。尽管 js 是单线程处理的,但是当可能的时候,它们会把操作转移到系统内核中去,目前大多数内核都是多线程的,它们可以在后台处理多种操作,当其中的一个操作完成的时候,内核通知 node 将合适的回调函数添加到轮询队列中等待时机执行。
node官网对 node 事件环的说明:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/
在解析 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()
设计为一旦在当前 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
是 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()
中,脚本仍具有运行完成的能力,同时允许在调用回调之前初始化所有的变量、函数等。它还具有不让事件循环继续的优点,适用于让事件循环继续之前,警告用户发生错误的情况
下面均为十进制数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
计算机内部,所有信息最终都是一个二进制值。每一个二进制位(bit)有0
和1
两种状态,因此八个二进制位就可以组合出256种状态,这被称为一个字节(byte)。也就是说,一个字节一共可以用来表示256种不同的状态,每一个状态对应一个符号,就是256个符号,从00000000
到11111111
ASCII 码一共规定了128个字符的编码,只占用了一个字节的后面7位,最前面的一位统一规定为0
ASCII 码是单字节编码,只能表示128个字符,明显无法满足汉字字符的编码需求,于是**就推出了自己的编码就是GB2312
,同样的日本、韩国也有自己的编码。不同的编码文件相互传递时,如果不知道编码格式就会出现乱码。
为了解决这一问题,试想如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。这就是 Unicode!
Unicode 只是一个字符集,只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。UTF-8 是 Unicode 的实现方式之一
UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。
UTF-8 的编码规则很简单,只有二条:
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是一种用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、网页中传输少量二进制数据
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
读取完所有数据后,触发rs
的end
事件调用ws
的end()
函数
流总共有四种类型:可读流、可写流、双工流、转化流
在之前可读流与可写流的源码学习中,可以知道它们继承于Readable
,Writeable
class ReadStream extends Readable {
_read() {
// ...
}
}
class WriteStream extends Writable {
_write(chunk, encoding, cb) {
// ...
}
}
之后调用rs
、ws
实例方法时,首先会调用父类的read
、write
方法,然后父类会反向调用子类的_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
Vue
中将runtime
模块分为runtime-core
核心代码及其他平台对应的运行时,那么VueRuntimeDOM
无疑就是解决浏览器运行时的问题,此包中提供了DOM 属性操作和节点操作一系列接口。
此方法主要针对不同的属性提供不同的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);
}
}
}
存放着所有的节点操作的方法
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
}
用户调用的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/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文件权限系统。
用户通过runtime-dom提供的createApp
方法创建组件并挂载。runtime-dom将dom操作的api作为参数传递给runtime-core中的createRenderer
方法创建渲染器,渲染器会提供两个方法render
和createApp
,这个createApp
方法以render
函数为参数返回给用户带mount
的app
对象,当用户执行mount
进行节点挂载时,此方法就会:1.根据用户传入的组件生成一个虚拟节点,2.将虚拟节点变成真实节点,插入到对应的容器中。
最近学习前端知识看到一个大佬写的 blog 感觉特别的棒,于是在阅读和学习之后打算总结(抄 😂 )到自己的知识体系中,建议直接看大佬的总结
关于作用域链部分内容,参考了另一位大佬的总结
作用域即函数或变量的可见区域。即函数或者变量不在这个区域内就无法访问到。
用函数形式以 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 规定,在某个花括号对 {}
的内部用 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
,除非想定义一个全局变量。
执行上下文就是当前 JavaScript 代码被解析和执行时所在的环境,也叫作执行环境;它是一个抽象概念。
JavaScript 中运行任何的代码都是在执行上下文中运行,在该执行上下文的创建阶段,变量对象、作用域链、this 指向会分别被确定。
window
对象;2.将 this
指针指向这个全局对象。一个程序中只能存在一个全局执行上下文eval
执行上下文:运行在 eval
函数中的代码也获得了自己的执行上下文,ES6 之后不再推荐使用 eval
函数执行上下文的生命周期包括三个阶段:创建阶段 => 执行阶段 => 回收阶段。
当函数被调用,但未执行任何其内部代码之前,会做以下三件事:
arguments
,提升函数声明和变量声明(变量的声明提前依赖于 var
关键字)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函数的执行上下文
上述代码执行上下文入栈出栈的全过程如图所示:
结合代码与流程图可知:
变量对象是一个类似于容器的对象,与作用域链、执行上下文息息相关,存储了在上下文中定义的变量和函数声明。
不同执行上下文中的变量对象稍有不同,具体看看全局上下文的变量对象和函数上下文的变量对象。
创建过程总共有三个阶段:
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
对象。
全局执行上下文的生命周期,与程序的生命周期一致,只要程序运行不结束(比如关掉浏览器窗口),全局执行上下文就会一直存在。其他所有的执行上下文,都能直接访问全局执行上下文里的内容。
多个作用域对应的变量对象串联起来组成的链表就是作用域链,这个链表是以引用的形式保持对变量对象的访问。作用域链保证了当前执行上下文对符合访问权限的变量和函数的有序访问。
当查找变量时,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象(即全局对象)。
下面以一个函数的创建和激活两个时期来说明作用域链是如何创建和变化的。
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
函数并不会立即执行,而是进入函数执行上下文的创建阶段[[scope]]
属性创建作用域链fooContext = {
Scope: foo.[[scope]],
}
fooContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: foo.[[scope]],
}
foo
作用域链顶端fooContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}
fooContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}
ExecStack = [
globalContext
];
node
中只包含 ECMAScript + 模块libuv
(多线程来实现的),核心是异步多线程的优点:可以做压缩合并等大量计算相关的(cpu 密集型);node 适合 I/O 密集型(web 应用的常见)
global
module.exports
,默认是 {}
,原因是 CommonJS 规范所有的代码写到文件中,文件内部会自带一个函数,这个函数执行的时候改变了 thisKoa
中有个包就是专门做这个的,叫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-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
中有一个包@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());
当使用不同请求方式时,会走到不同的处理中间件中。
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中间件的使用
在上一节中为了验证我们手写的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
}
改完后的代码少了一层嵌套,好像也没什么用,但是这是一种**。
catch
、Promise.resolve
、Promise.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
静态方法reject
与resove
实现原理一样,只是不具备等待功能,代码如下:class Promise {
...
static reject(err) {
return new Promise((resolve, reject) => {
reject(err);
})
}
...
}
Promise.all
与finally
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,那么会有等待效果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 })
})
}
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的成功或失败的结果了。
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
的结果
PENDING
状态的PromisePromise.resolve('1').then(data => {
console.log(data);
return new Promise(() => { }); // 返回一个promise,会采用他的状态;如果不成功也不失败,就不会向下执行了
}).then((data) => {
console.log(data)
});
Promise.race
;如果希望中断promise的链式调用,则需要返回一个pending状态的promise实现。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
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服务器,它足够强大便于生产和使用,用于本地测试和开发。
如图所示,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
的简单实现。不过我们还可以在此基础上优化其功能,比如:文件压缩传输及静态资源缓存。
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);
}
}
在响应文件前,可以要求浏览器再次访问此文件时多长时间内不要再来访问我了(只针对引用的资源);首次访问的资源,不会被设置。
设置也比较的简单,只需要设置响应头即可:
res.setHeader('Cache-Control', 'max-age=10'); // 以秒为单位,表示10s内我引用的其它资源不要再来访问了
res.setHeader('Expires', new Date(Date.now() + 10 * 1000).toUTCString()); // 此设置是为了兼容老版本(http1.0)
同时设置以上两种缓存,Cache-Control优先级更高。
强制缓存有个问题就是,当在到期时间内,文件发生了变化,浏览器无法及时获得最新的文件内容,同时当到达到期时间后,服务器需要再次响应请求发送文件,十分浪费性能。所以,我们采用协商缓存的方式:
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
根据最后修改时间,可能会出现时间变化后但内容没变,或者如果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伪代码
JavaScript 语言自身只有字符串数据类型,没有二进制数据类型。
但在处理像TCP流或文件流时,必须使用到二进制数据。因此在 Node.js中,定义了一个 Buffer 类,该类用来创建一个专门存放二进制数据的缓存区。
在 Node.js 中,Buffer 类是随 Node 内核一起发布的核心库,可以让 Node.js 处理二进制数据。
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>
console.log(buf3.length); // 6
console.log(buf3.toString()); // 小池(默认按照 utf-8 编码)
console.log(buf3.toString('base64')); // 5bCP5rGg
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 代表的是内存,不能随便调整大小。需要对内存进行拼接处理,先声明一个更大的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上
}
}
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;
}
console.log(Buffer.isBuffer(buf3)); // true
console.log(Buffer.isBuffer(123)); // false
console.log(buf3.indexOf('池')); // 3 下标索引,单位为字节
使用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(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
如果一个函数可以接收另一个函数作为参数 或 将另一个函数作为返回值,该函数就称之为高阶函数。
高阶函数最常见的形式之一就是回调函数
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
的方法。其原型链查找顺序如下图所示:
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__
属性,因此同时存在两条继承链
__proto__
属性,表示构造函数的继承,总是指向父类prototype
属性的 __proto__
属性,表示方法的继承,总是指向父类的 prototype
属性class A {
}
class B extends A {
}
B.__proto__ === A; //>> true
B.prototype.__proto__ === A.prototype; //>> true
最近因为工作需要,开发环境需要从 macos 切换到 win10。本人更习惯于类 unix 系统,打算安装 wsl2 并配置开发环境。故将搭建踩坑过程记录下来,如果你遇到了跟我一样的问题,希望本文可以帮助到你
适用于 Linux 的 Windows 子系统可让开发人员直接在 Windows 上按原样运行 GNU/Linux 环境(包括大多数命令行工具、实用工具和应用程序),且不会产生传统虚拟机或双启动设置开销
详细介绍可以查看官方文档
解决办法参考下面两篇文章,按照第一篇文章设置完成后,依旧出现无法
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
使用 homebrew 安装的 nginx 默认配置文件 nginx.conf 所在目录为/usr/local/etc/nginx
创建存放 SSL 证书相关文件目录
cd /usr/local/etc/nginx
mkdir ssl
cd ssl
openssl genrsa -des3 -out server.key 2048
以上命令是基于 des3 算法生成的 rsa 私钥,在生成私钥时必须输入至少 4 位的密码
openssl rsa -in server.key -out server.key
openssl req -new -x509 -key server.key -out ca.crt -days 3650
openssl req -new -key server.key -out server.csr
命令的执行过程中依次输入国家、省份、城市、公司、部门及邮箱等信息
openssl x509 -req -days 3650 -in server.csr -CA ca.crt -CAkey server.key -CAcreateserial -out server.crt
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;
......
}
sudo nginx -s reload
修改 /etc/hosts 文件,实现映射
vim /etc/hosts
# 添加本地 https 测试域名 ip 映射
127.0.0.1 localdomain.test
此时访问 https://localdomain.test/
,提示无法访问。Chrome 也没有“忽略证书继续前往”的选项,需要我们在系统上添加自签名证书到系统并修改为始终信任
*.cer
文件*.cer
文件,会出来一个安装对话框,选择安装到 “系统” 钥匙串直接在页面输入 thisisunsafe
即可继续访问
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
*/
首先需要定义三种状态,其次在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
*/
画个图便于理解:
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 |
| | | | | |
+---------------------+ +----------------------+ +-------------------+
yarn
初始化项目monorepo
的方式管理项目下的多个模块,npm
安装管理模块无法实现此功能,故使用yarn
来管理模块。yarn init -y
修改生成的package.json
文件,添加以下内容:
{
"private": true, // 表示该项目不会发布到npm上,只是用来管理的
"workspaces": [ // 指定管理的包的路径
"packages/*"
]
}
yarn add typescript -W
typescript
:npx tsc --init
(不带npx表示执行全局下的tsc,加npx执行的是当前目录下node_modules/.bin/tsc
命令去初始化)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的方式,好处就是每个组件都可以独立发布。
TS 中冒号后面的都为类型标识
string
,number
,bigint
,boolean
,null
,undefined
,symbol
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'}]
null
和 undefined
任何类型的子类型,但是在严格模式下,不能将 null
和 undefined
赋给其他类型变量
let name:number | boolean;
name = null;
void
类型只能接受 null
,undefined
。一般用于函数的返回值
function alertName(): void {
alert('My name is Tom');
}
let unusable: void = undefined;
严格模式下,不能将
null
赋给void
类型
never
类型任何类型的子类型,never
表示不存在的值,不能把其他类型赋值给 never
类型
出现的情况有三种:
symbol
类型symbol
表示独一无二
const s1 = Symbol('key');
const s2 = Symbol('key');
console.log(s1 == s2); // >> false
bigint
类型number
和 bigint
不兼容
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(){})
假设现在有一个文件夹,目录树结构如下:
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
的方法进行优化
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);
})
使用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);
})
npm i webpack-dev-server -D
webpack.config.js
和package.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"
},
用户初次调用渲染器提供的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
。
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;
}
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;
}
}
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);
}
}
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);
}
};
假设,我们写了下面这段代码,在同一个操作中,多次改变依赖的变量,目前我们写的代码,是肯定会多次执行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
是没有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);
}
global
上的属性
__dirname
绝对路径,指代的是当前文件所在的目录__filename
绝对路径,指代的是当前文件的路径exports
module
require()
setTimeout
、queueMicrotask
、setImmediate
process
代表进程,可以获得运行时一些环境和参数Buffer
二进制数据容器,用于处理二进制数据;主要用于文件操作platform
代码运行的平台(win32 => windows,darwin => mac);每个平台找一些用户文件,位置可能不一样cwd
当前工作目录(current working directory),运行时产生的一个路径,指向在哪里执行(可以改变)。相对路径相对的是工作目录,不是当前文件所在的目录。如果是一个确定的路径应使用绝对路径chdir
切换当前工作目录env
默认会读取全局的环境变量(也可以临时设置变量) npm install cross-env -g
全局安装 cross-env 设置临时的代码执行环境变量argv
用户执行时传递的参数;可以使用优秀的命令行管家解析命令行参数 npm install commander
process.nextTick
本质上,webpack
是一个用于现代 JavaScript
应用程序的静态模块打包工具。当 webpack
处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle
。
npm install webpack webpack-cli -D
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
属性告诉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
默认只能理解JavaScript
和JSON
文件loader
让webpack
能够去处理其它类型的文件,并将他们转换为有效模块,以供应用程序使用,以及被添加到依赖图中。例如,我们需要导入一个.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' }
+ ]
+ }
}
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
指定production
时,默认会启用各种性能优化的功能,包括构建结果优化以及webpack
运行性能优化;mode
指定development
时,则会开启debug
工具,运行时打印详细的错误信息,以及更快加速的增量编译构建。development
会将process.env.NODE_ENV
的值设为development
,production
会将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
用来设置模块内的全局变量webpack
的mode
默认为production
webpack serve
的mode
默认为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
同命令行配置1效果相同
package.json
"scripts": {
"build": "webpack --mode=production",
"dev": "webpack serve --mode=development"
},
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
在本地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 }
linux
的gpu
支持情况./node_modules/puppeteer/.local-chromium/linux-970485/chrome-linux/chrome --headless --no-sandbox --print-to-pdf chrome://gpu
同时,测试高德部分地图也是可行的
https://lbs.amap.com/demo/jsapi-v2/example/district-search/draw-district-boundaries
到此,感觉是高德做了某些限制,直接找高德寻求技术支持,提供相关问题后,得到如下答复
您好,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
导出也正常了将需要变成响应式的数据通过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
:因为外层没有收集依赖,虽然内层能改但是不会更新视图目录结构如下:
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处理器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方法之前,我们先介绍一个API:Reflect
。是用于在proxy
对象中操作的最佳拍档,通过下面一个例子进行说明:
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
方法主要是需要知道是增加值还是修改值,便于后续逻辑的执行;同时处理好数组新增带来的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
函数的使用效果:
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.name
和proxy.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
重新渲染视图,所以需要在取值时做依赖收集。
需要实现的逻辑是:
我们需要两个函数:一个依赖收集函数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;
}
}
需要说明的是,对数组的使用需要
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的依赖收集
}
}
}
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实现需要借助类的属性访问器
}
toRef
与toRefs
这两个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);
}
}
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.