0%

npm:

嵌套结构

我们都知道,执行 npm install 后,依赖包被安装到了 node_modules ,下面我们来具体了解下,npm 将依赖包安装到 node_modules 的具体机制是什么。

npm 的早期版本, npm 处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中。直到有子依赖包不在依赖其他模块。

扁平结构

为了解决以上问题,NPM3.x 版本做了一次较大更新。其将早期的嵌套结构改为扁平结构:

  • 安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在 node_modules 根目录。
  • npm 3.x 版本并未完全解决老版本的模块冗余问题,加入你的项目没有依赖B,但是依赖了不同B版本的A@1和A@2,由于在执行npm install,按照package.json依赖的顺序依次解析,则A@1和A@2在package.json的位置决定了node_modules的依赖结构,可能先依赖A@1也可能先依赖A@2,造成了不确定性。

Lock文件

为了解决 npm install 的不确定性问题,在 npm 5.x 版本新增了 package-lock.json 文件,而安装方式还沿用了 npm 3.x 的扁平化的方式。

package-lock.json 的作用是锁定依赖结构,即只要你目录下有 package-lock.json 文件,那么你每次执行 npm install 后生成的 node_modules 目录结构一定是完全相同的。

整体流程

好了,我们再来整体总结下上面的流程:

  • 检查 .npmrc 文件:优先级为:项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件

  • 检查项目中有无 lock 文件。

  • 1
    lock

    文件:

    • npm 远程仓库获取包信息

    • 根据

      1
      package.json

      构建依赖树,构建过程:

      • 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在 node_modules 根目录。
      • 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下放置该模块。
      • 注意这一步只是确定逻辑上的依赖树,并非真正的安装,后面会根据这个依赖结构去下载或拿到缓存中的依赖包
    • 在缓存中依次查找依赖树中的每个包

      • 不存在缓存:
        • npm 远程仓库下载包
        • 校验包的完整性
        • 校验不通过:
          • 重新下载
        • 校验通过:
          • 将下载的包复制到 npm 缓存目录
          • 将下载的包按照依赖结构解压到 node_modules

      存在缓存:将缓存按照依赖结构解压到 node_modules

    • 将包解压到 node_modules

    • 生成 lock 文件

pnpm

pnpm好处:

  • 安装速度快(非扁平的包结构,没有复杂的扁平算法,只更新变化的文件)

  • 节省磁盘空间,统一安装到磁盘的某个位置,项目中的node_modules通过hard-link的方式链接到实际安装地址

  • pnpm 与 npm/yarn 另外一个很大的不同就是支持了 monorepo,体现在各个子命令的功能上,比如在根目录下 pnpm add A -r, 那么所有的 package 中都会被添加 A 这个依赖,当然也支持 --filter字段来对 package 进行过滤。

原理:

比如安装bar包,根目录只包含安装的包bar,而node_modules目录下的bar包会软链接到.pnpm/bar/node_modules/bar@…,被bar依赖的包会被提升到.pnpm的根目录,其他依赖该包的也会软链接到这里,而bar,foo包硬链接到.pnpm store;总之,软链接解决了磁盘占用的问题,而硬链接解决了包的同步更新和统一管理问题。

嵌套结构的问题在于:

  • 包文件的目录可能会非常长
  • 重复安装包
  • 相同包的实例不能共享

而扁平结构也同样存在问题:

  • 依赖结构的不确定性(不同包依赖某个包的不同版本 最终安装的版本具有不确定性)可通过lock文件确定安装版本
  • 扁平化算法复杂,耗时
  • 非法访问未声明的包

monorepo:

多个项目或者包文件放到一个git仓库管理,解决代码复用问题,开发流程统一,高效管理多项目/包

实习的时候遇到一个写一个tag-input的需求,也就是在input输入后里面每次回车都生成一个标签,在每次改变input的padding的时候都涉及到回流重绘的问题,为了优化性能,减少回流重绘的次数,又复习了一遍回流重绘。

回流这一阶段主要是计算节点的位置和几何信息,那么当页面布局和几何信息发生变化的时候,就需要回流。比如以下情况:

  • 添加或删除可见的DOM元素
  • 元素的位置发生变化
  • 元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
  • 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。
  • 页面一开始渲染的时候(这肯定避免不了)
  • 浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

浏览器的优化机制

由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列化修改并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。但是!当你获取布局信息的操作的时候,会强制队列刷新,比如当你访问以下属性或者使用以下方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle()
  • getBoundingClientRect

因此浏览器不得不清空队列,触发回流重绘来返回正确的值,造成强制同步布局

减少回流和重绘

最小化重绘和重排

由于重绘和重排可能代价比较昂贵,因此最好就是可以减少它的发生次数。为了减少发生次数,我们可以合并多次对DOM和样式的修改,然后一次处理掉。

使用cssText:

比如我的tag-input组件,优化前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
methods:{
delTag(index){
this.tags.splice(index,1)
this.$nextTick(()=>{
let el = document.querySelector('.yh-tag:last-child')
let top = el.offsetTop - el.clientHeight
let left = el.offsetLeft + el.clientWidth
document.querySelector('.yh-tag-input .el-textarea__inner').style.paddingLeft = left + 10 +'px'
document.querySelector('.yh-tag-input .el-textarea__inner').style.paddingTop = top + 10 +'px'
document.querySelector('.yh-tag-input .el-textarea__inner').style.height = el.offsetTop + 40 +'px'
})
},
addTag(){
if(!this.inputData) return ;
this.tags.push(this.inputData)
this.$nextTick(()=>{
let el = document.querySelector('.yh-tag:last-child')
let top = el.offsetTop - el.clientHeight
let left = el.offsetLeft + el.clientWidth
document.querySelector('.yh-tag-input .el-textarea__inner').style.paddingLeft = left + 10 +'px'
document.querySelector('.yh-tag-input .el-textarea__inner').style.paddingTop = top + 10 +'px'
document.querySelector('.yh-tag-input .el-textarea__inner').style.height = el.offsetTop + 40 +'px'
})
this.inputData = ''
}
}
}

优化后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
methods:{
delTag(index){
this.tags.splice(index,1)
this.$nextTick(()=>{
// tag-box布局为absolute 重排开销较小 不用考虑对其他元素的影响
let el = document.querySelector('.yh-tag:last-child')
if(el) {
// 缓存布局信息,读写分离,一次回流+重绘
let top = el.offsetTop;
let left = el.offsetLeft + el.clientWidth
// 放入浏览器队列中执行
document.querySelector('.yh-tag-input .el-input__inner').style.cssText =
`padding-left:${left + 10}px;padding-top:${top - 5}px;height:${top+30}px`;
} else {
document.querySelector('.yh-tag-input .el-input__inner').style.cssText =
`padding-left:5px;padding-top:5px;`
}
})
},
addTag(){
if(!this.inputData) return ;
this.tags.push(this.inputData)
this.$nextTick(()=>{
let el = document.querySelector('.yh-tag:last-child')
if(el) {
let top = el.offsetTop
let left = el.offsetLeft + el.clientWidth
document.querySelector('.yh-tag-input .el-input__inner').style.cssText=
`padding-left:${left + 10}px;padding-top:${top - 5}px;height:${top+30}px`;
}
})
this.inputData = ''
},
focus() {
this.$refs.input.focus();
}
}

或者改为使用class

更进一步优化

input-tag组件就是输入后失去焦点或者按下回车形成标签,并且光标始终跟随在最后,要可以自动换行,起初的做法是input后形成的标签用el-tag显示,并且el-tag是子组件,定位为绝对位置,父盒子相对位置,卡在了获取每次点击无法去自动获取光标,起初通过document.querySelector拿到最后一个标签元素,设置input的padding-left为最后一个el-tag的el.offsetLeft+el.clientWidth,padding-top为el.offsetTop,但是这样每次去读取的时候会最少造成一次回流,感觉交互体验一般,因此,解决办法就是将input放到和el-tag同级上面,并且设置外面的盒子为display:flex,flex-wrap:warp,这样input会跟着el-tag走,光标自动锁定。

批量修改DOM,读写dom分离

1
2
3
4
5
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}

上面这段代码每次循环都读取box的offsetWidth属性值,再写style.width,每次循环都会强制浏览器刷新对列,可以将width缓存

1
2
3
4
5
6
const width = box.offsetWidth;
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = width + 'px';
}
}

css3硬件加速(GPU加速)

1. 使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘 。

2. 对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

因为使用css动画在合成线程上执行,如果采用GPU进行栅格化,合成效率会更高

webpack 打包流程:

1.读取入口文件内容

2.分析入口文件,递归读取模块所依赖的文件内容,生成 AST 语法树

3.根据 AST 语法树,生成浏览器能够运行的代码 具体细节: 先配置好 webpack.config.js 文件,创建 add.js 和 minus.js 在 index.js 中引入:

add.js

1
2
3
export default (a, b) => {
return a + b;
};

minus.js:

1
2
3
export const minus = (a, b) => {
return a - b;
};

index.js:

1
2
3
4
5
6
import add from "./add.js";
import { minus } from "./minus.js";
const sum = add(1, 2);
const division = minus(2, 1);
console.log(sum);
console.log(division);

index.html:

1
2
3
4
5
6
7
8
9
10
11
<!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>
<script src="../dist/main.js"></script>
</head>
<body></body>
</html>

核心类是 WebpackCompiler.js,在构造函数中先获取 entryPath,初始化钩子,plugins 可以设置在不同的编译阶段,先给 webpack 定义五个生命周期,并在 run 方法适当的时机嵌入,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class WebpackCompiler {
constructor(config) {
this.config = config;
this.depsGraph = {};
this.root = process.cwd(); //当前项目地址
this.entryPath = this.config.entry;
//插件在这些生命周期中被调用
this.hooks = {
run: new tapable.SyncHook(),
beforeCompile: new tapable.SyncHook(),
afterCompile: new tapable.SyncHook(),
afterPlugins: new tapable.SyncHook(),
afterEmit: new tapable.SyncWaterfallHook(["hash"]),
};
const plugins = this.config.plugins;
if (Array.isArray(plugins)) {
plugins.forEach((plugin) => {
//每个均是实例,调用实例上的一个方法即可,传入当前Compiler实例
//plugin.run(this);
plugin.apply.call(plugin, this);
});
}
run() {
//编译开始
this.hooks.run.call(); //启动项目
this.hooks.beforeCompile.call(); //编译前运行
this.buildModule(this.entryPath);
this.hooks.afterCompile.call(); //编译后运行
this.outputFile();
this.hooks.afterPlugins.call(); //执行完plugins后运行
this.hooks.afterEmit.call(); //结束后运行
}
}

关于 tapable

可以参考这篇文章:https://juejin.cn/post/6955421936373465118 webpack 插件是一种基于 Tapable 的钩子类型,它在特定时机触发钩子时会带上足够的上下文信息,插件定义的钩子回调中,与这些上下文背后的数据结构,接口产生 sideEeffect,进而影响到编译状态和后续流程 自定义一个插件:

1
2
3
4
5
6
7
8
class MyPlugin {
apply(compiler) {
compiler.hooks.run.tap("myPlugin", (compilation) => {
console.log("my plugin");
});
}
}
module.exports = MyPlugin;

compiler.hooks.run.tap,其中 run 为 tapable 仓库提供的钩子对象,为订阅函数,tap 用于注册回调,关于 tapable 钩子: SyncHook:同步执行,无需返回值 SyncBailHook:同步执行,无需返回值,返回 undefined 终止 SyncWaterfallHook,同步执行,上一个处理函数的返回值时下一个的输入,返回 undefined 终止 SyncLoopHook:同步执行,订阅的处理函数有一个的返回值不是 undefined 就一直循环它 异步钩子: AsyncSeriesHook:异步执行,无需返回值 AsyncParallelHook:异步并行钩子 AsyncSeriesBailHook:异步执行,无需返回值,返回 undefined 终止 …

获取模块内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//获取源码,经过loader转换生成代码
getSourceByPath(modulePath) {
//console.log("path", modulePath);
let content = fs.readFileSync(modulePath, "utf8");
//拿module中的匹配规则与路径进行匹配
const rules = this.config.module.rules;
for (let i = 0; i < rules.length; i++) {
let { test, use } = rules[i];
let len = use.length;
//匹配到了开始走loader,从后往前
if (test.test(modulePath)) {
function changeLoader() {
//先拿最后一个loader
let loader = require(use[--len]);
content = loader(content);
if (len > 0) {
changeLoader();
}
}
changeLoader();
}
}
return content;
}

分析模块和收集依赖

根据模块被 loader 编译后的内容和路径解析生成 ast 树,这里用@babel/paser 引入模块内容,用到一个选项 sourceType,设置为 module,表示我们要解析的是 ES 模块

遍历 ast 收集依赖,就是用 import 语句引入的文件路径收集起来,将收集起来的路径转换为绝对路径放到 deps 里,遍历 AST 用@babel/traverse 依赖包,第一个参数是 AST,第二个参数是配置对象,最后 ES6 转为 ES5 用@babel/core 和@babel/preset-env

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//根据路径解析源码,file是入口路径
parse(source, file) {
let ast = parser.parse(source, {
sourceType: "module", //解析的是ES5模块
});
//console.log(ast);
//收集依赖
let dependencies = {};
traverse(ast, {
//获取通过Import引入的模块
//对ast解析遍历语法树,负责替换,删除和添加节点
ImportDeclaration({ node }) {
const dirname = path.dirname(file);
const abspath = "./" + path.join(dirname, node.source.value);
dependencies[node.source.value] = abspath; //基于import获取文件需要的依赖
},
});
//console.log(dependencies);
//es6转es5
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
//console.log(code);
const moduleInfo = { file, code, dependencies };
return moduleInfo;
}

构建模块

1.先传入主模块路径和内容,获得模块信息放到 temp 数组

2.循环里面获得主模块的依赖 deps

3.遍历主模块的依赖 deps,调用 parse 获得依赖模块信息,继续放到 temps 数组中 实际就是将层层依赖进行收集打平

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
buildModule(modulePath) {
const source = this.getSourceByPath(modulePath); //根据路径拿到源码
const entry = this.parse(source, modulePath);
//console.log(entry);
const temp = [entry];
for (let i = 0; i < temp.length; i++) {
const deps = temp[i].dependencies;
if (deps) {
for (const key in deps) {
if (deps.hasOwnProperty(key)) {
let content = this.getSourceByPath(deps[key]);
temp.push(this.parse(content, deps[key]));
}
}
}
}
temp.forEach((moduleInfo) => {
this.depsGraph[moduleInfo.file] = {
deps: moduleInfo.dependencies,
code: moduleInfo.code,
};
});
this.depsGraph = JSON.stringify(this.depsGraph);
}

此时生成的 depsGraph:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
file: './src/index.js',
code: '"use strict";\n' +
'\n' +
'var _add = _interopRequireDefault(require("./add.js"));\n' +
'var _minus = require("./minus.js");\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
'var sum = (0, _add["default"])(1, 2);\n' +
'var division = (0, _minus.minus)(2, 1);\n' +
'console.log(sum);\n' +
'console.log(division);',
dependencies: { './add.js': './src\\add.js', './minus.js': './src\\minus.js' }
}

解决浏览器无法识别部分es5代码

但是还不能执行 code 中 index.js 这段代码,因为浏览器不会识别 require 和 exports,因为没有定义这些 require 和 exports 对象,因此要自己定义require函数,由于打包后的代码require的是相对路径,因此之前保存在graph里的dependency派上用场,里面对应的就是哥哥相对路径结点对应的绝对路径,所以执行absReuire去递归调用require,拿到的就是绝对路径,并且这个absRequire作为参数传给立即执行函数,立即执行函数执行eval代码,会去读取定义的absrequire和exports,实际就是闭包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bundler(file) {
return `
(function (graph) {
function require(file) {
function absRequire(relPath) {
return require(graph[file].deps[relPath]);
}
var exports = {};
(function (require, exports, code) {
eval(code);
})(absRequire, exports, graph[file]?.code);
return exports;
}
require('${file}');
})(${this.depsGraph});`;
}

实际上执行 require(‘./src/index.js’)后,执行

1
2
3
(function (require, code) {
eval(code);
})(absRequire, graph[file].code);

执行 eval,也就执行 打包后的代码,但是又会调用 require 函数,也就是我们传递的 absRequire,而执行 absRequire 就执行 return require(graph[file].deps[relPath]),前面已经将import的文件路径转为绝对路径将执行外面这个 require,继续周而复始执行立即执行函数,调用 require,路径已经转化为绝对路径,成功执行相应的 eval(code) 但是在执行 add.js 的 code,会遇到 exports 还没定义的问题,

1
2
3
4
5
6
7
8
// add.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports["default"] = void 0;
var _default = function _default(a, b) {
return a + b;
};
exports["default"] = _default;

定义一个 exports 使用,执行 add.js 代码时会在这个空对象上增加属性并返回 outPutFile():

1
2
3
4
5
6
7
8
9
10
11
//输出文件
outputFile() {
const code = this.bundler(this.entryPath);
//拿到输出地址
let outPath = path.join(
this.config.output.path,
this.config.output.filename
);
console.log(code);
fs.writeFileSync(outPath, code); //写入
}

解决重复import

在webpack的源码中,重复import不会被重复打包是通过模块的缓存机制实现的。当模块被导入时,webpack会将模块的路径作为键,模块的内容作为值存储在一个缓存对象中。如果下次再次导入该模块时,webpack会先检查缓存对象中是否已经存在该模块的缓存,如果有就直接使用缓存中的模块内容,避免重复打包。

具体来说,当webpack遇到重复import时,会先解析对应的模块路径,然后从缓存对象中查找是否已经有该模块的缓存。如果有,就直接使用缓存中的模块内容,否则就重新构建该模块,然后将该模块的路径和内容加入缓存对象中。

此外,webpack还提供了一些优化选项,如splitChunks和DllPlugin,可以进一步提高打包性能和效率,避免重复打包。

例如:

1
2
3
4
5
<template>
<Transition>
<div>我是需要过渡的元素</div>
</Transition>
</template>

可以将模板编译为虚拟DOM:

1
2
3
4
5
6
7
8
9
10
function render(){
return{
type:Transition,
children:{
default(){
return {type:"div",children:"我是需要过渡的元素"}
}
}
}
}

Transition组件的子节点被编译为默认插槽

  1. Transition组件本身不会渲染任何额外的内容,它只是通过默认插槽读取过渡元素,并渲染需要过渡的元素

  2. Transition组件的作用,就是在过渡元素的虚拟结点上添加transition相关的钩子函数

渲染器在渲染需要过渡的虚拟结点时,会在合适时机调用附加在该虚拟节点上得到过渡相关的生命周期相关的钩子函数。

渲染器在渲染需要过渡的虚拟结点时,会在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期钩子函数,体现在mountElement和unmount函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function mountElement(vnode, container, anchor) {
const el = (vnode.el = createElement(vnode.type));
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
PaymentMethodChangeEvent(null, child, el);
});
}
if (vnode.props) {
for (const key in props) {
patchProps(el, key, null, vnode.props[key]);
}
}
//判断一个VNode是否需要过渡
const needTransition = vnode.transition;
if (needTransition) {
//调用transition.beforeEnter钩子,将DOM元素作为参数传递
vnode.transition.beforeEnter(el);
}
insert(el, container, anchor);
if (needTransition) {
vnode.transition.enter(el);
}
}

unmount.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//卸载元素时应该调用transition.leave钩子函数
function unmount(vnode) {
//判断VNode是否过渡处理
const needTransition = vnode.transition;
if (vnode.type === Fragment) {
vnode.children.forEach((c) => unmount(c));
return;
} else if (typeof vnode.type === "object") {
if (vnode.shouldKeepAlive) {
vnode.keepAliveInstance._deActive(vnode);
} else {
unmount(vnode.component.subTree);
}
return;
}
const parent = vnode.el.parentNode;
if (parent) {
//将卸载动作封装到performRemov函数中
const performRemove = () => parent.removeChild(vnode.el);
}
if (needTransition) {
//如果需要过渡处理,调用transition.leave钩子
//同时将DOM元素和performRemove函数作为参数传递
vnode.transition.leave(vnode.el, performance);
} else {
//如果不需要过渡处理,则直接执行卸载操作
performance();
}
}

Transition组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const Transition = {
name: "Transition",
setup(props, { slots }) {
return () => {
//通过默认插槽获取需要过渡的元素
const innerVNode = slots.default();
//在过渡元素的VNode对象上添加transition相应的钩子函数
innerVNode.transition = {
beforeEnter(el) {
//设置初始状态,添加enter-from和enter-active类
el.classList.add("enter-from");
el.classList.add("enter-active");
},
enter(el) {
//下一帧切换到结束状态
nextFrame(() => {
//移除enter-from类,添加enter-to类
el.classList.remove("enter-from");
el.classList.add("enter-to");
//监听transitioned事件完成收尾工作
el.addEventListener("transitioned", () => {
el.classList.remove("enter-to");
el.classList.remove("enter-active");
});
});
},
leave(el, performRemove) {
//设置离场过渡的初始状态,添加leave-from和leave--active类
el.classList.add("leave-from");
el.classList.add("leave-active");
//强制refolw使得初始状态生效
document.body.offsetHeight;
//在下一帧修改状态 ?
nextFrame(() => {
el.classList.remove("leave-from");
el.classList.add("leave-to");
el.addEventListener("transitioned", () => {
el.classList.remove("leave-to");
el.classList.remove("leave-active");
//调用transition.leave钩子函数的第二个参数,完成DOM元素的卸载
performRemove();
});
});
},
};
return innerVNode;
};
},
};

对应任何类型T,keyof T的结果为该类型上索引公有属性key的联合

1
2
3
4
5
6
7
8
9
10
11
12
interface Eg1 {
name: string,
readonly age: number
}
type T1=keyof Eg1//name|age
class Eg2{
private name: string,
public readonly age:number,
protected home:string
}
//T2被约束为age,非公有属性不能被keyof获取
type T2=keyof Eg2

T[k]索引查询:

1
2
3
4
5
6
7
8
interface Eg1 {
name:string,
readonly age: number,
}
type V1=Eg1['name']//string
type V2=Eg1['name'|'age']//string | number
type V3=Eg1['name'|'age222']//any
type V4=Eg1[keyof Eg1]//strign | number

[]中的key有不存在于T中的,则是any

第一个元素类型:

1
type First<T extends any[]> = T extends [infer F,...infer R] ? F : nerver

获取元组长度

1
type Length<T extends readonly any[]> = T<'length'>

元组转数组

注意加上readonly,因为as const 会生成如下类型:const tuple: readonly[‘tesla’,’model 3’,’model X’,’model Y’]

1
2
3
4
5
6
const tuple = ['tesla','model 3','model X','model Y'] as const
const res: TupleToObject<typeof tuple>
// expected { tesla: 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}
type TupleToObject<T extends readonly any[]> = {
[K in T[number]]: K
}

Ts内置类型工具原理分析:

实现Partial:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*核心实现就是通过映射类型遍历T上的所有属性
然后将每个属性设置为可选属性 */
type Partial<T> = {
[P in keyof T]?: T[P]
}
/*将制定的key变为可选类型*/
type PartialOptional<T,K extends keyof T> = {
[P in K]?: T[P]
}
/*example:type Eg1={key1?:string,key2?:number}*/
type Eg1 = PartialOptional<{
key1: string,
key2:number,
key3:''
},'key1'|'key2'>

实现Record:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//key 为联合类型中的每个子类型,类型为T
//keyof any得到的是string | number |symbol
//类型key的类型只能为string | number | symbol
type Record<K extends keyof any, T>={
//遍历key,将值设为T
[P in K]:T
}
/*example: type Eg2 = {a:B,b:B}*/
interface A {
a:string,
b:number
}
interface B {
key1: number,
key2: string
}
type Eg2=Record<keyof A,B>
/*Partial,Readonly,Pick同态,其实现需要输入类型T拷贝属性,因此属性修饰符例如readonly,?:会被拷贝*/
type Eg=Pick<{readonly a?: string},'a'>
//keyof T 辅助拷贝传入类型的属性
//type Eg=<{readonly a?: string},'a'>
type Pick<T,K extends keyof T>={
[P in K]:T[P]
}
/*在Pick中,P in keyof T,T为输入的类型,而keyof T则遍历了输入类型,而Record的实现中,并没有遍历输入的类型,K只是约束为keyof any的子类型*/

实现Readonly

1
2
3
4
5
6
7
8
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]
}
//example
type Eg=MyReadonly<{
key1: string,
key2: number
}>

实现Pick:

1
2
3
4
5
6
7
8
9
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
interface Todo{
title: string
description: string
completed: boolean
}
type A=MyPick<Todo,'title'| 'completed'>

Exclude:

1
2
3
4
5
/*遍历T中的所有子类型,如果该子类型约束于U(存在于U,兼容于U),则返回nerver类型,否则返回该子类型 */
type Exclude<T,U> = T extends U ? nerver : T
//example: type Eg = 'key1'
type Eg=Exclude<'key1'|'key2','key2'>
//nerver表示一个不存在的类型,与其他类型联合后,是没有nerver的

Extract:

1
2
//提取联合类型T和U的交集
type Extract<T,U> = T extends U ? T : never

Omit:

1
2
3
4
5
6
7
//Omit<T,K>从类型T中剔除K中的所有属性
//用Pick实现
type Omit = Pick<T,Exclude<keyof T,K>>
//利用映射类型
type Omit2<T,K extends keyof any> = {
[P in Exclude<keyof T,K>]:T[P]
}

Parameters和ReturnType

1
2
3
4
5
6
7
8
9
//Parameters获取函数参数类型,将每个参数类型放在一个元组
type Parameters<T extends Function> = T extends (...args: infer P)=>any ? P : never;
//example
type Eg = Parameters<(arg1:string,arg2:number) => void>
//type Eg=[arg1:string,arg2:number]
/*Parameters约束参数T必须是个函数类型,具体实现就是判断T是否是函数类型,是就是要infer P让ts自己推到出函数的参数类型,并将推导结果存到类型P,否则
返回never*/
//ReturnType获取函数的返回值类型
type ReturnType<T extends Function> = T extends (...args: any)=> infer R ? R : never

ConstructorParameters:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//ConstructorParameters可以获取类的构造函数的参数类型,存在一个元组中
//用infer进行推导
type ConstructorParameters<T extends abstract new (...args:any)=>any>=T extends abstract new (...args:infer P) =>any ? P : never
/**
* @example
* type Eg = string;
*/
interface ErrorConstructor {
new(message?: string): Error;
(message?: string): Error;
readonly prototype: Error;
}
type Eg = ConstructorParameters<ErrorConstructor>;

/**
* @example
* type Eg2 = [name: string, sex?: number];
*/
class People {
constructor(public name: string, sex?: number) {}
}
type Eg2 = ConstructorParameters<typeof People>
//T约束为抽象类:既可以赋值为抽象类,也可以赋值为普通类
/*使用typeof 类作为类型和使用类作为类型的区别:
当把类直接作为类型,该类型约束的必须是类的实例,即该类型获取的是该类上的实例属性和实例方法
当把typeof 类作为类型,约束的满足该类的类型,该类型获取的是该类上的静态属性和方法
*/
//example
class People {
name: string;
age: number;
constructor()
}
//p1可以正常赋值
const p1: People = new People()
//等号后的People报错
const p2: People =People
//p3报错,类型People中缺少属性'prototype',但类型"typeof People中需要该属性
const p3: typeof People = new People()
const p4: typeof People = People

自定义的Ts高级类型工具:

SymmetricDifference:

1
2
3
4
//SymmetricDifference<T,U>获取没有同时存在于T和U内的类型
type SymmetricDifference<T,U>=Exclude<T|U,T&U>
//example:type Eg ='1' | '4'
type Eg = SymmetricDifference<'1'|'2'|'3','2'|'3'|'4'>

FunctionKeys:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//获取T中所有类型为函数的key组成的联合类型
type NonUndefined<T>= T extends undefined ? nerver : T
type FunctionKeys<T extends object> = {
[K in keyof T]: NonUndefined<T[K]> extends FunctionKeys ? K : nerver
}[keyof T]
/**
* @example
* type Eg = 'key2' | 'key3';
*/
type AType = {
key1: string,
key2: () => void,
key3: Function,
};
type Eg = FunctionKeys<AType>;
//最后经过{省略}[keyof T]索引访问,取到的为值类型的联合类型never | key2 | key3,计算后就是key2 | key3;
/*T[]是索引访问操作,可以取到值的类型,T['a'|'b']可以依次取到值的类型进行联合,
T[keyof T]则是取到T所有值的类型类型 nerver和其他类型进行联合时,nerver不存在,nerver | number | string = number | string */

OptionalKeys:

1
2
3
4
5
6
7
8
9
10
11
//OptionalKeys提取T中所有可选类型的key组成的联合类型
/*用映射类型遍历所有key,通过Pick<T,P>提取当前key和类型,利用同态拷贝会拷贝可选修饰符的特性,利用{}extends {当前key:类型} 判断是否是可选类型*/
type OptionalKeys<T> = {
[P in keyof T]: {} extends Pick<T,P> ? P : nerver
}[keyof T]

type Eg = OptionalKeys<{key1?: string, key2: number}>
//{key1?:string}
//利用{}extends {当前key:类型} 判断是否是可选类型,{}和只包含可选参数类型{key1?:string}是兼容这一特性,extends前面的{}替换为object也可以
type Eg2={} extends {key1:string} ? true : false //false
type Eg3={} extends {key1?:string} ? true : false //true

增强Pick:

PickByValue

1
2
3
4
5
6
7
8
9
10
11
12
13
//PickByValue提取指定类型的键值对
//获取T中类型不为nerver的类型组成大的联合类型
type TypeKeys<T> = T[keyof T]
type PickByValue<T,V> = Pick<T,
TypeKeys<{[P in keyof T]: T[P] extends V ? P : nerver}>
>
/*example
type Eg = {
key1: number;
key3: number
}*/
type Eg = PickByValue<{key1:number,key2:string,key3:number},number>

PickByValueExtract:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Ts类型兼容的原因,string可以分配给string|number
type PickByValueExact<T,V>=Pick<T,
TypeKeys<{[P in keyof T]: [T[P]] extends [V]}
? ([V] extends [T[P]] ? P : never)
: never
>
>
//type Eg={b:number}
type Eg1=PickByValueExact<{a:string,b:number},number>
type Eg2=PickByValueExact<{a:string,b:number,c:number|undefined},number>
//type Eg2={b:number,c:number|undefined}
/*给泛型套一层元组,规避extends的分发式联合类型的特性
利用两个类型互相兼容的方式判断是否相同*/
//example
type Eq1<X,Y> = X extends Y ? true : false
type Eq2<X,Y> = [X] extends [Y] ? true : false
type Eq3<X,Y>= [X] extends [Y]
? ([Y] extends [X] ? true : false)
: false

增强Omit:

1
2
3
4
5
6
7
//Omit<T,K>从类型T中剔除K中的所有属性
//用Pick实现
type Omit = Pick<T,Exclude<keyof T,K>>
//利用映射类型
type Omit2<T,K extends keyof any> = {
[P in Exclude<keyof T,K>]:T[P]
}

OmitByValue:

1
2
3
4
5
6
7
8
9
10
type TypeKeys<T> = T<keyof T>
type OmitByValue<T,V> = Omit<T,
TypeKeys<{[P in keyof T] : T[P] extends V ? P : never}>
>

type OmitByValueExact<T,V> =Omit<T,
TypeKeys<{[P in keyof T]: [T[P]] extends [V]
?([V] extends [T[P]] ? [T[P]] : never)
: never
}>

Overwrite和Assign

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//从T中提取存在于U中的key和对应的类型
type Intersection<T extends object,U extends object>=Pick<T,
Extract<keyof T,keyof U> & Extract<keyof U,keyof T>
>
type Eg=Intersection<{key1: string},{key1: string,key2:number}>
/*利用Pick提取指定的key组成的类型,通过Extract<keyof T,keyof U>提取同时存在于T和U中的key,
Extract<keyof U,keyof T>同理,做两次Extract然后再交叉类型的原因在于处理类型的兼容推导问题*/
//Diff<T,U>从T中排除存在于U中的key和类型
type Diff<T extends object,U extends object> = Pick<
T,Exclude<keyof T,keyof U>
>
type Overwrite<
T extends object,
U extends object,
I = Diff<T,U> & Intersection<T,U>
> = Pick<I,keyof I>
/*Diff<T,U>先获取存在于T不存在于U中的key和其类型,
Intersection<U,T>从U中提取存在于T中的key和类型,即后者同名key和类型,在进行交叉合并*/

type Assign<
T extends object,
U extends object,
I = Diff<T,U> &U
> = Pick<IDBCursor,keyof I>

Intersection:

1
2
3
4
5
6
7
8
9
10
11
//从T中提取存在于U中的key和对应的类型
type Intersection<T extends object,U extends object>=Pick<T,
Extract<keyof T,keyof U> & Extract<keyof U,keyof T>
>
type Eg=Intersection<{key1: string},{key1: string,key2:number}>
/*利用Pick提取指定的key组成的类型,通过Extract<keyof T,keyof U>提取同时存在于T和U中的key,
Extract<keyof U,keyof T>同理,做两次Extract然后再交叉类型的原因在于处理类型的兼容推导问题*/
//Diff<T,U>从T中排除存在于U中的key和类型
type Diff<T extends object,U extends object> = Pick<
T,Exclude<keyof T,keyof U>
>

UnionToIntersection:

1
2
3
4
5
6
7
8
9
10
//将联合类型转变为交叉类型
type UnionToIntersection<T> =(T extends any
? (arg: T) => void
: never
)extends (arg: infer U) => void ? U : never
/*T extends any ? (arg: T) => void : never一定走true分支,构造一个逆变的联合类型
(arg1: T1)=>void | (arg2: T2)=>void | (arg3: T3)=>void
再利用第二个extends配合infer推导得到U的类型,利用infer对协变类型的特性得到交叉类型*/
type Eg = UnionToIntersection<{key1:string} |{key2: number}>

1.三栏布局的实现

2 float顺序是ABC还是ACB,flex布局中的属性讲一下

3 js的数据类型

开始吟唱

4 Number和BigInt的区别

答得好像不是很完善?

5 判断数据类型

typeof instanceOf Object.prototype.toString.call,constructor

6 typeof object===?为什么

7 讲讲原型链

8看代码输出:

1
2
3
4
5
6
7
var a=100
function b(){
//变量提升
a=10
}
b()
console.log(a)//10 因为b里面是给全局变量a重新赋值
  1. x
1
2
3
4
5
6
7
8
9
var a=100
function b(){
//变量提升
a=10
return
function a(){}
}
b()
console.log(a)//100,b进入执行上下文栈后,a函数变量提升,然后a赋值,这里是创建了一个新的局部变量a,return后b函数执行完毕,b出栈,里面的局部变量a被销毁,外面访问的a是全局变量a,即100

10 var let const区别

11 看代码输出:关于this指向

1
2
3
4
5
6
7
8
9
10
11
var name="win"
let obj={
name:'obj',
a:()=>{
console.log(this.name)
}
}
let obj1={
name:'obj1'
}
obj.a.call(obj1)//win

12

1
2
3
4
5
6
7
8
9
10
11
let arr=['a','b','c','d','e','f','g','h']
function print(){
for(var i=0;i<arr.length;i++){
img=new Image()
img.src=arr[i]
img.onload=function(){
console.log(i)
}
}
}
print()//8,8,8,8,8,8,8,8

13 如果img的加载速度很快呢

还是8个8,因为是异步任务,无论多快都要等到同步任务执行完才执行异步任务

14 跨域问题的解决方案

cors josonp 代理服务器

15 数组有哪些方法 哪些会改变数组本身

开始吟唱,改变数组本身的有pop,push,reverse,shift,unshift

16 讲讲简单请求和复杂请求

简单请求:get post,head,客户端服务器只发生一次请求:

复杂请求:先发送一次预检请求,Get,post,head之外的请求,请求头中包含自定义头部字段,向服务器发送了application/json格式的数据,在浏览器服务器正式通信前,浏览器先发生OPTION请求进行预检,获知该服务器是否允许该实际请求,这一层OPTION称为预检请求,服务器成功响应预检请求后才会发送真正的请求并且挈带真正的数据

客户端和服务器发送两次请求

17 设置哪个字段允许浏览器携带cookie

cookie有同源限制,如果发送跨域,浏览器不会自动带上cookie,如果服务端设置cors,Access-Control-Allow-Credentials: true Access-Control-Allow-Origin: [特定域名] // 不可以是*,客户端,XMLHttpRequest发请求需要设置withCredentials=true,fetch 发请求需要设置 credentials = include

参考:https://juejin.cn/post/6859939491994402824

18 手写map:

1
2
3
4
5
6
7
8
9
10
11
function myMap(fn,context){
if(typeof fn !== 'function'){
throw new Error("this first argument must be a function")
}
let res=[]
let arr=this
for(let i=0;i<arr.length;i++){
res[i]=fn.call(context,arr[i],i,arr)
}
return res
}

MDN链接:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/map

19 用正则表达式实现千分符

1
2
let num='12345677799'
console.log(num.toString().replace(/(\d)(?=(\d{3})+$)/g,'$1,'))

x(?=y)先行断言,匹配x仅仅匹配x后面跟着y,从后往前,以3的倍数进行分割,$1表示子字符串的匹配,将其替换为’$1’,即在每一个匹配到的子字符串后加,

20 说一下this.$nextTick

watch的本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数,利用了effect和options.scheduler选项,利用副作用函数重新执行时的可调度性,一个watch本身会创建一个effect,当这个effect依赖的响应式数据变化时,会执行该effect的调度函数,即scheduler,这里的scheduler可以认为是“回调”,所以我们只需要在scheduler中执行用户通过watch注册的回调函数即可

比如以下例子:

1
2
3
4
5
6
const data={foo:1}
const obj = new Proxy(data,{})
watch(obj,()=>{
console.log("数据变化了")
})
obj.foo++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//首先判断source类型,如果是函数类型,则说明用户直接传递了getter函数,这时直接使用用户的getter函数
//如果不是函数类型,调用traverse函数递归读取
//traverse函数递归读取,当任意属性发送变化时都能触发回调函数的执行
function watch(source, cb) {
let getter;
//如果source是函数,则说明传递的是getter,则直接赋值给getter,触发读取操作,建立联系
if (typeof source === "function") {
getter = source;
} else {
//按照原来的实现调用traverse
getter = () => traverse(source);
}
//定义旧值和新值
let oldValue, newValue;
//开启lazy选项并把返回值存储到effectfn中一遍后续手动调用
const effectfn = effect(
//执行getter
() => getter,
{
lazy: true,
scheduler() {
//在scheduler重新执行一遍副作用函数得到的是新值
newValue = effectfn();
cb(newValue, oldValue);
//更新旧值
oldValue = newValue;
},
}
);
//第一次执行得到的值时旧值
oldValue = effectfn();
}
//能读取一个对象上的任意属性,当任意属性发生变化时都能够触发回调函数执行
function traverse(value, seen = new Set()) {
//如果读取的是原始值,或者已经被读取过,什么都不做
if (typeof value !== "object" || value === null || seen.has(value)) return;
//将数据添加到seen中,代表遍历地读取过,避免循环引用
seen.add(value);
//假设value是一个对象,实验for...in读取对象得到每个值,并递归地调用traverse进行处理
for (let k in value) {
traverse(value[k], seen);
}
}

如何拿到新值和旧值:lazy选项创建了一个懒执行的effect,最下面部分我们手动调用effectFn函数得到的返回值就是旧值,即第一次执行得到的值,当变化发生触发scheduler调度函数执行时,会重新调用effectFn函数并得到新值,这样我们总可以拿到旧值和新值,接着把它们传递给回调函数cb即可,再用新值更新旧值

立即执行的watch和回调执行时机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function watch(source, cb,options={}) {
let getter;
//如果source是函数,则说明传递的是getter,则直接赋值给getter
if (typeof source === "function") {
getter = source;
} else {
//按照原来的实现调用traverse
getter = () => traverse(source);
}
//定义旧值和新值
let oldValue, newValue;
//提取scheduler调度函数为一个独立的job函数
const job = () => {
newValue=effectfn()
cb(newValue, oldValue);
//更新旧值
oldValue = newValue;
}
//开启lazy选项并把返回值存储到effectfn中一遍后续手动调用

const effectfn = effect(
//执行getter
() => getter,
{
lazy: true,
scheduler: ()=>{
//在调度函数中判断flush是否为post,如果是,将其放到微任务队列
if(options.flush==='post'){
const p= Promise.resolve()
p.then(job)
}else{
job()
}
}
}
);
if(options.immediate){
job()
}else{
//第一次执行得到的值时旧值
oldValue = effectfn();
}
}

由于回调函数时立即执行,所以第一次回调执行时没有旧值,因此此时回调函数的oldValue值为undefined

过期的副作用:

watch回调函数接收第三个参数onInvalidate,它是一个函数,类似于事件监听器,我们可以使用onInvalidate函数注册一个回调,这个回调函数会在当前副作用函数过期时执行

使用例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//过期副作用
watch(obj,async(newValue,oldValue,onInvalidate) => {
//定义一个标志,代表当前副作用函数是否过期,默认为false,代表没有过期
let expired = false
//调用onInvalidate函数注册一个过期回调
onInvalidate(()=>{
//过期时设置expired为true
expired=true
})
//发送请求
const res=await fetch('path/to/request')
//只有副作用函数没过期时才执行后序操作
if(!expired){
finalData=res
}
})
//第一次修改
obj.foo++;
setTimeout(()=>{
//第二次修改
obj.foo++
},200)

watch处理过期回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function watch(source, cb,options={}) {
let getter;
//如果source是函数,则说明传递的是getter,则直接赋值给getter
if (typeof source === "function") {
getter = source;
} else {
//按照原来的实现调用traverse
getter = () => traverse(source);
}
//定义旧值和新值
let oldValue, newValue;
//cleanup存储用户注册的过期回调
let cleanup
function onInvalidate(fn){
cleanup=fn
}
//提取scheduler调度函数为一个独立的job函数
const job = () => {
newValue=effectfn()
//调用回调函数前,先调用过期回调
if(cleanup){
cleanup()
}
//将onInvalidate作为回调函数第三个参数,以便用户使用
cb(newValue, oldValue,onInvalidate);
//更新旧值
oldValue = newValue;
}
//开启lazy选项并把返回值存储到effectfn中一遍后续手动调用

const effectfn = effect(
//执行getter
() => getter,
{
lazy: true,
scheduler: ()=>{
//在调度函数中判断flush是否为post,如果是,将其放到微任务队列
if(options.flush==='post'){
const p= Promise.resolve()
p.then(job)
}else{
job()
}
}
}
);
if(options.immediate){
job()
}else{
//第一次执行得到的值时旧值
oldValue = effectfn();
}
}

第一次修改obj.foo,立即执行,watch回调函数调用onInvalidata,注册过期回调,接着A请求,加入1000ms返回结果,我们在200ms后第二次修改obj,foo,又会导致watch回调函数执行,会执行过期回调,将expired设为true,则请求A的结果返回将被抛弃,避免过期副作用回调函数带来的影响

一个响应式系统:

  • 当读取操作发生时,将副作用函数收集到桶里
  • 当设置操作发生时,从桶里取出副作用函数执行

为了让副作用函数无论是什么形式都能被收集到桶里,设置一个affectEffect全局变量来存储被注册的副作用函数

1
2
3
4
5
6
7
8
//用一个全局变量存储被注册的副作用函数
let activeEffect;
//副作用函数栈,解决effect函数嵌套时,内层副作用覆盖activeEffect的值
let effectStack = [];
function effect(fn, options = {}) {
activeEffect = fn;
fn()
}

分支切换时清除遗留副作用函数

1
2
3
4
const data={ok:true,text:'hello'}
effect(function effectFn(){
document.body.innerText=obj.ok?obj.text:'not'
})

当obj.ok改为false时,此时obj.text不会被读取,只会触发obj.ok的读取操作,理想情况下副作用函数effectFn不应该被字段obj.text所对应的依赖集合收集,然而,整个依赖关系仍然保持,则单修改obj.text会重新执行副作用函数,这是不应该的,解决这个问题,需要在每次副作用函数执行时,把它从所有与之关联的依赖集合中删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
let activeEffect
function effect(fn){
const effectFn=()=>{
//当effectFn执行,将其设置为当前激活的副作用函数
activeEffect=effectFn
fn()
}
//activeEffect.dep用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps=[]
//执行副作用函数
}
const bucket = new WeakMap();
//在get拦截函数内调用track函数追踪变化
function track(target, key) {
//禁止追踪时直接返回
if (!activeEffect || !shouldTrack) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
//把当前激活的副作用函数添加到依赖集合deps中
deps.add(activeEffect);
//将其添加到activeEffect.deps数组中
activeEffect.deps.push(deps);
}
//避免副作用函数产生遗留
function cleanup(effectfn) {
//遍历副作用函数的依赖集合数组
for (let i = 0; i < effectfn.deps.length; i++) {
//deps是依赖集合
let deps = effectfn.deps[i];
//将该副作用函数从相关的依赖集合中移除
deps.delete(effectfn);
}
effect.deps.length = 0;
}

解决无限循环

但是这样会引起无限循环,在trigger函数中,我们遍历effects集合,执行副作用函数,当副作用函数执行,cleanup清除,就是从effects集合中将当前执行的副作用函数删除,但是副作用函数的执行又会导致其重新被收集到集合中华,造成无限循环,可以构造另外一个Set集合并遍历它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);

effects &&
effects.forEach((effectfn) => {
if (activeEffect != effectfn) {
//只有当trigger触发执行的副作用函数和当前正在执行的副作用函数不相同时才触发执行,如果副作用函数中执行obj.foo++,则会读取obj.foo的值又会设置obj.foo的值,track函数操作将副作用收集到桶中,trigger函数将副作用函数拿出来来执行,上一个副作用函数还没执行完毕就要执行下一次,否会出现栈溢出,
effectsToRun.add(effectfn);
}
});
effectsToRun.forEach((effectfn) => effectfn());
//effect&&effect.forEach(fn=>fn())//会产生无限执行
}

嵌套effect与effect栈:
当在组件Foo中渲染另一个组件,会发生effect嵌套:

1
2
3
4
5
6
7
8
const Bar={
render(){}
}
const Foo={
render(){
return <Bar/>
}
}

此时就发生了effect嵌套:

1
2
3
4
5
6
7
effect(()=>{
Foo.render()
//嵌套
effect(()=>{
Bar.render()
})
})

然而,如果只是用activeEffect来存储通过effect函数注册的副作用函数,这意味着任意时刻activeEffect所存储的副作用函数只有一个,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值,为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用函数执行时,将当前副作用函数入栈,待副作用函数执行完毕将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数,这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//用一个全局变量存储被注册的副作用函数
let activeEffect;
//副作用函数栈,解决effect函数嵌套时,内层副作用覆盖activeEffect的值
let effectStack = [];
//options选项可以实现调度执行,懒执行等
function effect(fn, options = {}) {
const effectfn = () => {
//调用cleanup完成清除工作
cleanup(effectfn);
//副作用函数入栈
effectStack.push(effectfn);
//当effectfn执行时,将其设置为当前激活的副作用函数
activeEffect = effectfn;
//副作用函数执行完毕,出栈
const res = fn();
effectStack.pop();
//把activeEffect还原为之前的值
activeEffect = effectStack[effectStack.length - 1];
return res;
};
//activeEffect.deps用来存储所有与该副作用函数相关联的依赖集合
effectfn.deps = [];
if (!options.lazy) {
effectfn();
}
return effectfn;
}

编译优化指的是编译器将模板编译为渲染函数的过程中,尽可能多的提取关键信息,并以此指导生成最优代码的过程,优化的方向主要是区分动态内容和静态内容,并针对不同的内容采用不同的优化策略

动态节点收集与补丁标志:

Vue2中:渲染器在运行时得不到足够的信息,传统diff算法无法利用编译时提取得到的关键信息,这导致渲染器在运行时不可能去做相关优化

Vue3:会将编译时得到的关键信息附着在它生成的虚拟DOM上,这些信息会通过虚拟DOM传递给渲染器,最终渲染器会因为这些关键信息执行“快捷路径”,提升性能

传统虚拟DOM:

1
2
3
4
5
6
7
const vnode={
tag:'div'.
children:[
{tag:'div',children:'foo'},
{tag:'p',children:ctx.bar}
]
}

加了patchFlag:

1
2
3
4
5
6
7
const vnode={
tag:'div'.
children:[
{tag:'div',children:'foo'},
{tag:'p',children:ctx.bar,patchFlag:1}//动态节点
]
}

patchFlag属性就是补丁标志。理解为一系列数字标记,不同数字值的不同赋予它不同意义:

1:代表节点有动态的textContent

2:代表元素有动态class绑定

3:代表元素有动态style绑定

我们可以在虚拟结点的创建阶段,将它的动态子节点提取出来,并将其存储到该虚拟结点的dynamicChildren中,与普通虚拟节点比较,它多出了一个dynamicChildren,把含有该属性的虚拟节点称为块,即Block,一个Block还需要可以收集它的所有动态子节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const PatchFlags={
TEXT:1,
CLASS:2,
STYLE:3
}
const vnode={
tag:'div'.
children:[
{tag:'div',children:'foo'},
{tag:'p',children:ctx.bar,patchFlag:1}//动态节点
],
//将children中的动态节点提取到dynamicChildren数组
dynamicChildren:[
{tag:'p',children:ctx.bar,patchFlag:PatchFlags.TEXT}//动态节点
]
}

收集所有动态子节点:

在渲染函数内,对createVNode的函数的调用是层层嵌套,并且函数的执行顺序是内层先执行,外层后执行

1
2
3
4
5
6
7
render(){ 
return createVNode('div',{},[
createVNode('div',{},[
...
])
])
}

当外层createVNode函数执行时,内层的createVNode已经执行完毕,因此,为了让外层的Block结点能够收集到内层结点,就需要一个栈结构的数据来临时存储内层的动态节点,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//动态节点栈:临时存储内层的动态节点
const dynamicChildrenStack = []
//当前动态节点的集合
let currentDynamicChildren = null
//openBlock用来创建一个新的动态节点集合,将currentDynamicChildren初始化为空数组
function openBlock() {
dynamicChildrenStack.push((currentDynamicChildren=[]))
}
//closeBlock用来将通过openBlock创建的动态节点集合从栈中弹出
function closeBlock() {
currentDynamicChildren = dynamicChildrenStack.pop()
}
//在createVNode函数内部,检测节点是否存在补丁标志,如果存在,则说明该节点是动态节点,将其添加到当前动态节点集合currentDynamicChildren中
function createVNode(tag,props,children,flags) {
const key = props && props.key
props && delete props.key
const vnode = {
tag,
props,
children,
key,
patchFlags: flags
}
if(typeof flags !== 'undefined' && currentDynamicChildren) {
//动态节点,将其添加到当前动态节点集合中
currentDynamicChildren.push(vnode)
}
}
function render() {
//...
//使用createBlock代替createNode来创建Block
//每次调用createBlcok之前先调用openBlock
return (openBlock(),createBlock('div',null,[
createVNode('p',{class: 'foo'},null,1),
createVNode('p',{class:'bar'},null)
]))
}
function createBlock(tag,props,children) {
//block本质是一个vnode
const block = createVNode(tag,props,children)
//内层的createNode函数已经执行完毕,这里的currentDynamicChildren存储的就是属于当前Block的所有动态子节点
block.dynamicChldren = currentDynamicChildren
//关闭block
closeBlock()
return block
}

利用逗号运算符保证渲染函数的返回值仍然是VNode对象,任何作为Block的节点都应该使用createBlock函数完成虚拟节点创建,由于createVNode函数和createBlock函数时由内向外,因此,当createBlock执行时,内层所有createVNode函数已经执行完毕,currentDynamicChildren存储的就是当前Block的所有动态子节点,将currentDynamicChildren赋值给dynamicChldren,完成了动态子节点的收集

渲染器运行时支持:

优先检测虚拟DOM是否存在动态节点集合,即dynamicChildren数组,如果存在,直接调用patchBlockChildren函数完成更新,这样,渲染器只会更新动态节点,而跳过所有静态结点,进行针对性的靶向更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function patchElement(n1,n2){
const el=n1.el=n2.el
const oldProps = n1.props
const newProps = n2.props
if(n2.patchFlags) {
///靶向更新
if(n2.patchFlags===1){
//只更新Text
}else if(n2.patchFlags===2) {
//更新class
}else if(n2.patchFlags===3){
//...
}else{
//全量更新
for(const key in newProps) {
if(newProps[key]!==oldProps[key]) {
patchProps(el,key,oldProps[key],newProps[key])
}
}
for(const key in oldProps) {
if(!(key in newProps)){
patchProps(el,key,oldProps[key],null)//卸载
}
}
}
}
//调用patchChildren处理children
patchChilren(n1,n2,el)
}

由于Block会收集所有动态子节点,所以对动态子节点的操作时忽略DOM层级结构的,会带来额外的问题,即v-if,v-for等结构化指令会影响DOM层级结构,使之不稳定,会间接导致Block树的对比算法失效,解决办法就是让带有v-if和v-for等指令的结点也作为Block角色,

v-if指令的结点:

1
2
3
4
5
6
7
8
9
10
11
12
<div>
<section v-if="foo">
<p>
{{a}}
</p>
</section>
<div v-else>
<p>
{{a}}
</p>
</div>
</div>

当foo为true或false,block收集到的动态节点均是:

1
2
3
4
5
6
7
const block = {
tag:'div',
dynamicChildren: [
{tag: 'p',children: ctx.a,patchFlags:1}
]
//...
}

在Diff中就不会做更新,然而更新前后标签不一样,不做更新会产生bug,结构化指令导致更新前后模板的结构发生变化,即模板结构不稳定,因此需要让v-if/v-else等结构化指令的结点也作为Block角色

即上面这段模板会构成一颗Block树:

1
2
3
Block(div)
-Block(Section v-if)
-Block(div v-else)

父级Block除了会收集动态子节点外,也会收集子Block,因此两个子Block将作为父Block的动态节点被收集到父级Block的dynamicChildren数组中

1
2
3
4
5
6
7
const block ={
tag: 'div',
dynamicChildren:[
//Block(section v-if)或者Block(div v-else),key值根据不同Block发生变化
{tag:'section',{key:0},dynamicChildren:[...]}
]
}

v-for指令的结点:

下面的模板:

1
2
3
4
5
6
7
<div>
<p v-for="item in list">
{{item}}
</p>
<i>{{foo}}</i>
<i>{{bar}}</i>
</div>

只有最外层的div标签作为Block:

更新前:

1
2
3
4
5
6
7
8
9
10
const prevBlock = {
tag:'div',
dynamicChildren:[
{tag:'p',children:1,1},
{tag:'p',children:2,1},
{tag:'i',children:ctx.foo,1},
{tag:'i',children:ctx.bar,1}

]
}

更新后:

1
2
3
4
5
6
7
8
9
const prevBlock = {
tag:'div',
dynamicChildren:[
{tag:'p',children:1,1},
{tag:'i',children:ctx.foo,1},
{tag:'i',children:ctx.bar,1}

]
}

更新前后的block动态节点个数不一致,为了让DOM树有稳定的结构,让带有v-for指令的标签也作为Block角色,使用类型为Fragment的结点来表达v-for指令的渲染结果,并作为Block角色

1
2
3
4
5
6
7
8
9
const block = {
tag:'div',
dynamicChildren: [
//这是一个Block,它有dynamicChildren
{tag:Fragment,dynamicChildren:[/*v-for结点*/]}
{tag:'i',children:ctx.foo,1},
{tag:'i',children:ctx.bar,1}
]
}

然而Fragment本身收集的动态节点依然会结构不稳定,就是更新前后一个block的dynamicChildren数组中收集的动态节点数量或顺序不一致,导致我们无法直接进行靶向更新,只能用传统diff算法

静态提升:

1
2
3
4
5
6
7
const hoist1=createVNode('p',null,'static text'),
function render(){
return (openBlock(),createBlock('div',null,[
hoist1,
creatVNode('p',nulll,ctx.title,1)
]))
}

在渲染函数内只会持有对静态结点的引用,当响应式数据变化,是的渲染函数重新执行时,并不会重新创建静态的虚拟结点,避免了额外的性开销

预字符串化:

静态提升的虚拟节点或虚拟节点树本身是静态的,可以将其预字符串化:

比如:

1
2
3
4
5
6
<div>
<p></p>
<p></p>
...
<p></p>
</div>
1
2
3
4
5
6
7
const hoistStatic=createStaticVNode('<p></p><p></p>...')

render() {
return(openBlock(),createBlock('div',null,[
hoistStatic
]))
}

好处:

  • 大块的静态内容可以通过innerHTML进行设置,性能上有优势
  • 减少创建虚拟节点产生的性能开销和内存占用

总结

除了Block和补丁标志,Vue3还在编译性能上做了其他优化:

  • 静态提升:把纯静态的结点提升到渲染函数外,渲染函数内只会持有对静态结点的引用,当响应式数据变化,使得渲染函数重新执行时,并不会重新创建静态的虚拟结点,避免了额外的性开销
  • 预字符串化:在静态提升基础上,对静态结点进行字符串化,这样能减少创建虚拟节点产生的性能开销和内存占用,大块的静态内容可以通过innerHTML进行设置
  • 缓存内联事件处理函数:避免造成不必要的组件更新
  • v-once指令:代码中存在setBlockTracking(-1)函数调用,用来暂停动态结点的收集,也就是说使用v-once包裹的动态节点不会被父级Block收集,被v-once包裹的动态节点在组件更新时,不会参与DIff操作,缓存全部或者部分虚拟节点,避免组件更新时重新创建虚拟DOM带来的性能开销,也可以避免无用的Diff操作