近期个人练手项目

2022
13/05

想到一些有意思的小项目,业余时间可以搞一搞。目前在搞的是一个轻量级的NodeJs微服务脚手架。

1.NodeJs轻量级微服务脚手架

目前在搞,半成品。(2022-8:初步完成,基本功能已实现,顺便还搞了几个npm包。后期若使用时有BUG再完善)

2.简洁的浏览器主页

带分类导航、搜索、注册登录、后管系统等。(计划年前搞一搞吧,如果有时间的话)

2022-8-23:浏览器主页打算使用React+ts全家桶开发;后管系统用自己的vue3+ts脚手架;后端使用NodeJs(单服务)。

3.vue3+ts+element-plus 后管系统

菜单权限及角色、用户管理功能完善。这个之前未完成,也是一个半成品。(2022-8:UI已初步搞定,一些交互及用户管理的业务逻辑待后续完善。)

———

我的npm地址,有兴趣的也可以去看下:https://www.npmjs.com/~huisir

细说Javascript中的ESM及其与CommonJS区别

2022
24/01

esm是什么

esm 是将 javascript 程序拆分成多个单独模块,并能按需导入的标准。和webpackbabel不同的是,esm 是 javascript 的标准功能,在浏览器端和 nodejs 中都已得到实现。使用 esm 的好处是浏览器可以最优化加载模块,比使用库更有效率

esm 标准通过importexport语法实现模块变量的导入和导出

esm 模块的特点

  1. 存在模块作用域,顶层变量都定义在该作用域,外部不可见
  2. 模块脚本自动采用严格模式
  3. 模块顶层的this关键字返回undefined
  4. esm 是编译时加载,也就是只有所有import的模块都加载完成,才会开始执行
  5. 同一个模块如果加载多次,只会执行一次

export

export语句用来导出模块中的变量

// 导出变量
export let count = 1;
export const CONST_VAR = 'CONST_VAR';
// 导出函数
export function incCount() {
    count += 1;
}
// 导出类
export class Demo {

}

function add(x) {
    return x + count;
}
// 使用export导出一组变量
export {
    count,
    add,
    // 使用as重命名导出的变量
    add as addCount,
}

// 导出default
export default add

// 合并导出其他模块的变量
export { name } from './esm_module2.js'
export * from './esm_module2.js'

import

import语句用来导入其他模块的变量

// 导入变量
import { count, incCount, CONST_VAR } from './esm_module1.js';

// 通过as重命名导入的变量
import { addCount as renamedAddCount } from './esm_module1.js';

// 导入默认
import { default as defaultAdd } from './esm_module1.js';
import add from './esm_module1.js';

// 创建模块对象
import * as module1 from './esm_module1.js';

export 导出的是值引用

esm 模块和 commonjs 模块的一个显著差异是,cjs 导出的是值得拷贝,esm 导出的是值的引用。当模块内部的值被修改时,cjs 获取不到被修改后的值,esm 可以获取到被修改后的值。

cjs 例子

// cjs_module1.js
var count = 1;
function incCount() {
    count += 1;
}

module.exports = {
    count: count,
    incCount: incCount,
}

// cjs_demo.js
var { count, incCount } = require('./cjs_module1.js');

console.log(count); // 1
incCount();
console.log(count); // 1

esm 例子

// esm_module1.js
let count = 1;
function incCount() {
    count += 1;
}

export {
    count,
    incCount,
}

// esm_demo.js
import { count, incCount } from './esm_module1.js';

console.log(count); // 1
incCount();
console.log(count); // 2

从实现原理上来看,cjs 的 module.exports是一个对象,在运行期注入模块。在导出语句module.exports.count = count执行时,是给这个对象分配一个count的键,并赋值为1。 这之后模块中的count变量再怎么变化,都不会干扰到module.exports.count

esm 中的export { count }是导出了count变量的一个只读引用,等于说使用者读取count时,值的指向还是模块中count变量的值。

可以看阮一峰的这篇文章

在 html 中使用 esm

使用script标签引入 esm 文件,同时设置type=module,标识这个模块为顶级模块。浏览器将 esm 文件视为模块文件,识别模块的import语句并加载。

<script src="./esm_main.js" type="module"></script>

如果不设置type=module,浏览器认为该文件为普通脚本。检查到文件中存在import语句时,会报如下错误

esm的加载机制

esm 标准没有规定模块的加载细节,将这些留给具体环境实现。大致上分为下面四个步骤:

解析:实现读取模块的源代码并检查语法错误

加载:递归加载所有import的模块

链接:对每个加载的模块,都生成一个模块作用域,该模块下的所有全局声明都绑定到该作用域上,包括从其他模块导入的内容

运行时:完成所有import的加载和链接,脚本运行每个已经加载的模块中的语句。当运行到全局声明时,什么也不会做(在链接阶段已经将声明绑定到模块作用域)。

可以看下 mdn 上的这篇深入 esm 的文章

动态加载模块

esm 的一个重要特性是编译时加载,这有利于引擎的静态分析。加载的过程会先于代码的执行。却也导致import导入语句不能在函数或者if语句中执行

// 报语法错误
if (true) {
    import add from './esm_module1.js';
}

es2020 提案引入import()函数,用来动态加载模块,并且可以用在函数和if语句中。

import('./esm_module1.js')
  .then(module => {
    console.log(module);
  })

import()函数接受要加载的模块相对路径,返回一个Promise对象,内容是要加载的模块对象。

使用import()函数还可以实现根据变量动态加载模块

async function getTemplate(templateName) {
	let template = await import(`./templates/${templateName}`);
	console.log(template);
}

getTemplate("foo");
getTemplate("bar");
getTemplate("baz");

ESM与CommonJS模块化区别

CommonJS语法

// 导出
module.exports = {}
module.exports.a = xxx
// 导入
var xxx = require('xxx');

ESM语法

// 导出
export const aaa = xxx
export bbb
export default xxx

// 导入
import aaa from './xxx.js'
import {a,b} from './xxx.js'
import * as aaa from './xxx.js'
import { default as defaultAdd } from './esm_module1.js';

两者区别

1.CommonJS 模块输出的是一个值的拷贝,ESM模块输出的是值的引用

当模块内部的值被修改时,cjs 获取不到被修改后的值,而ESM可以。

2.CommonJS 模块是运行时加载,ESM 模块是编译时加载

因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。

而 ESM不是对象,它的对外接口只是一种静态定义,一个“符号连接”,在代码静态解析阶段就会生成。必须写道模块顶部。

es2020 提案引入import()函数,可用来动态加载模块。

3.CommonJS 是同步加载模块,ESM是异步加载模块

CJS 由于运行机制是值拷贝,require会阻塞后续代码。值拷贝完成会缓存其结果。

JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。也就是说模块不会缓存运行结果,而是动态地去被加载的模块取值。

4.ESM 引用的模块变量是只读的

由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。

5.ESM 同一个模块如果加载多次,只会执行一次

什么是pnpm以及pnpm的安装与使用

2021
08/11

什么是pmpm

pnpm是 Node.js 的替代包管理器。它是 npm 的直接替代品,但速度更快、效率更高。

多快?快3倍!请参阅此处的基准。

为什么效率更高?当您安装软件包时,我们会将其保存在您机器上的全局存储中,然后我们会从中创建一个硬链接,而不是进行复制。对于模块的每个版本,磁盘上只保留一个副本。例如,当使用 npm 或 yarn 时,如果您有 100 个使用 lodash 的包,则磁盘上将有 100 个 lodash 副本。Pnpm 可让您节省数 GB 的磁盘空间!

pnpm优势

pnpm 拥有 Yarn 超过 npm 的所有附加功能:

  1. 安全。与 Yarn 一样,pnpm 有一个包含所有已安装包校验和的特殊文件,用于在执行代码之前验证每个已安装包的完整性。
  2. 离线模式。pnpm 将所有下载的包 tarball 保存在本地注册表镜像中。当包在本地可用时,它从不发出请求。使用该--offline参数可以完全禁止 HTTP 请求。
  3. 速度。pnpm 不仅比 npm 快,而且比 Yarn 快。无论是冷缓存还是热缓存,它都比 Yarn 快。Yarn 从缓存中复制文件,而 pnpm 只是从全局存储中链接它们。

pnpm使用

官网: https://pnpm.js.org/installation/

全局安装

npm install pnpm -g

设置源

//查看源
pnpm config get registry
//切换淘宝源
pnpm config set registry https://registry.npmmirror.com

使用

pnpm install 包 //
pnpm i 包
pnpm add 包 // -S 默认写入dependencies
pnpm add -D // -D devDependencies
pnpm add -g // 全局安装

移除

pnpm remove 包 //移除包
pnpm remove 包 --global //移除全局包

更新

pnpm up //更新所有依赖项
pnpm upgrade 包 //更新包
pnpm upgrade 包 --global //更新全局包

设置存储路径

pnpm config set store-dir /path/to/.pnpm-store

Typescript 中的 interface 和 type 区别

2021
30/04

interface 和 type在官方规范中所描述的区别:

  • An interface can be named in an extends or implements clause, but a type alias for an object type literal cannot.
  • An interface can have multiple merged declarations, but a type alias for an object type literal cannot.

翻译如下:

  • 接口可以在extendsimplements子句中命名,但对象类型字符串的类型(type)不能。
  • 一个接口可以有多个合并声明,但对象类型字符串的类型别名(type)不能。

但是没有太具体的例子。以下为实际异同。

相同点

都可以描述一个对象或者函数

interface

interface User {
 name: string
 age: number
}

interface SetUser {
 (name: string, age: number): void;
}

type

type User = {
 name: string
 age: number
};

type SetUser = (name: string, age: number): void;

都允许拓展(extends)

interface 和 type 都可以拓展,并且两者并不是相互独立的,也就是说 interface 可以 extends type, type 也可以 extends interface 。 虽然效果差不多,但是两者语法不同。

interface extends interface

interface Name { 
 name: string; 
}
interface User extends Name { 
 age: number; 
}

type extends type

type Name = { 
 name: string; 
}
type User = Name & { age: number };

interface extends type

type Name = { 
 name: string; 
}
interface User extends Name { 
 age: number; 
}

type extends interface

interface Name { 
 name: string; 
}
type User = Name & { 
 age: number; 
}

不同点

type 可以而 interface 不行

type 可以声明基本类型别名,联合类型,元组等类型

// 基本类型别名
type Name = string

// 联合类型
interface Dog {
 wong();
}
interface Cat {
 miao();
}

type Pet = Dog | Cat

// 具体定义数组每个位置的类型
type PetList = [Dog, Pet]

type 语句中还可以使用 typeof 获取实例的 类型进行赋值

// 当你想获取一个变量的类型时,使用 typeof
let div = document.createElement('div');
type B = typeof div

其他骚操作

type StringOrNumber = string | number; 
type Text = string | { text: string }; 
type NameLookup = Dictionary<string, Person>; 
type Callback<T> = (data: T) => void; 
type Pair<T> = [T, T]; 
type Coordinates = Pair<number>; 
type Tree<T> = T | { left: Tree<T>, right: Tree<T> };

interface 可以而 type 不行

interface 能够声明合并

interface User {
 name: string
 age: number
}

interface User {
 sex: string
}

/*
User 接口为 {
 name: string
 age: number
 sex: string 
}
*/

总结

一般来说,如果不清楚什么时候用interface/type,能用 interface 实现,就用 interface , 如果不能就用 type 。其他更多详情参看官方规范文档。

js经典易错合集(一)

2021
03/04

1.函数的this指向

var length = 10
function fn(){
  console.log(this.length)
}
fn() //10
//函数的this指向调用者,这里调用者为window
//var声明的变量会挂载到window上

var obj={
  length:2,
  show:function(f){
    this.length=5
    f()
    arguments[0]()
  },
  show2:(f)=>{
    this.length=5
    f()
    arguments[0]() //箭头函数是没有arguments的,这里会报错
  }
}

obj.show(fn) //10 1
// 普通函数里如果嵌套了非箭头函数,这时嵌套的函数里的this在未指定情况下应该指向window,这里相当于调用者依然是window
// arguments[0]() 调用者是arguments 他的长度是所有实参数量,而非形参
obj.show(fn,1) //10 2
//箭头函数this指向定义是外层非箭头函数的上下文
//箭头函数内嵌套普通函数,普通函数指向箭头函数的指向
//在这里show2箭头函数的this指向的是window,如果没有this.length=5,那么fn()将打印出的是10
obj.show2(fn) //5  arguments is not defined

2.Promise.resolve()及async

var a = Promise.resolve()
var a2 = Promise.resolve(1)
console.log(a) //Promise {} 返回Promise对象,状态为fulfilled
console.log(typeof a) //object
a.then((e)=>{console.log(e)}) //undefined
a2.then((e)=>{console.log(e)}) //1

async function fn(){
  var b = Promise.resolve()
  b.c=1
  return b
}

var f = fn()
console.log(f) //Promise {} 返回Promise对象,状态为fulfilled
console.log(f.c) //undefined

async function fn2(){
  var b ={}
  b.c=1
  return b
}

var f2 = fn2()
console.log(f2) //Promise {<fulfilled>: {c:1}}
console.log(f2.c) //undefined

async function fn3(){
  return 1
}

var f3 = fn3()
console.log(f3) //Promise {<fulfilled>: 1}

3.对象引用和函数参数怪异问题

var obj = {a:1}
function fn(o){
   o.a=2
   o={b:3}
   o.c=4
}
fn(obj)
console.log(obj) //{a:2}

//解析:函数的参数相当申明了一个新变量,以上代码等同于
var obj = {a:1}
var o = obj
o.a=2
o={b:3}
o.c=4
console.log(obj)

//---------------------------

var obj = {a:1}
function fn(o){
   o.a=2
   o.c=4
}
fn(obj)
console.log(obj) //{a:2,c:4}

一文吃透浏览器渲染基本原理

2021
25/03

45大多数设备的刷新频率是60Hz,也就说是浏览器对每一帧画面的渲染工作要在16ms内完成,超出这个时间,页面的渲染就会出现卡顿现象,影响用户体验。前端的用户体验给了前端直观的印象,因此对B/S架构的开发人员来说,熟悉浏览器的内部执行原理显得尤为重要。

一、浏览器主要组成与浏览器线程

1.1 浏览器组件

浏览器大体上由以下几个组件组成,各个浏览器可能有一点不同。

  • 界面控件 – 包括地址栏,前进后退,书签菜单等窗口上除了网页显示区域以外的部分
  • 浏览器引擎 – 查询与操作渲染引擎的接口
  • 渲染引擎 – 负责显示请求的内容。比如请求到HTML, 它会负责解析HTML、CSS并将结果显示到窗口中
  • 网络 – 用于网络请求, 如HTTP请求。它包括平台无关的接口和各平台独立的实现
  • UI后端 – 绘制基础元件,如组合框与窗口。它提供平台无关的接口,内部使用操作系统的相应实现
  • JS解释器 – 用于解析执行JavaScript代码
  • 数据存储持久层 – 浏览器需要把所有数据存到硬盘上,如cookies。新的HTML5规范规定了一个完整(虽然轻量级)的浏览器中的数据库 web database

注意:chrome浏览器与其他浏览器不同,chrome使用多个渲染引擎实例,每个Tab页一个,即每个Tab都是一个独立进程。

1.2 浏览器中的进程与线程

Chrome浏览器使用多个进程来隔离不同的网页,在Chrome中打开一个网页相当于起了一个进程每个tab网页都是一个进程,都有由其独立的渲染引擎实例。因为如果非多进程的话,如果浏览器中的一个tab网页崩溃,将会导致其他被打开的网页应用。另外相对于线程,进程之间是不共享资源和内存地址空间的,所以不会存在太多的安全问题,而由于多个线程共享着相同的内存地址空间和资源,所以会存在线程之间有可能会恶意修改或者获取非授权数据等复杂的安全问题。

每个进程中有多个线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

1. GUI 渲染线程

GUI渲染线程负责渲染浏览器界面HTML元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。在Javascript引擎运行脚本期间,GUI渲染线程都是处于挂起状态的,也就是说被冻结了.

2. JavaScript引擎线程

JS为处理页面中用户的交互,以及操作DOM树、CSS样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理。如果JS是多线程的方式来操作这些UI DOM,则可能出现UI操作的冲突;如果JS是多线程的话,在多线程的交互下,处于UI中的DOM节点就可能成为一个临界资源,假设存在两个线程同时操作一个DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果,当然我们可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性,JS在最初就选择了单线程执行。

GUI渲染线程与JS引擎线程互斥的,是由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JavaScript线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致。当JavaScript引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到引擎线程空闲时立即被执行。由于GUI渲染线程与JS执行线程是互斥的关系,当浏览器在执行JS程序的时候,GUI渲染线程会被保存在一个队列中,直到JS程序执行完成,才会接着执行。因此如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

3. 定时触发器线程

浏览器定时计数器并不是由JS引擎计数的, 因为JS引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确, 因此通过单独线程来计时并触发定时是更为合理的方案。

4. 事件触发线程

当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但由于JS的单线程关系所有这些事件都得排队等待JS引擎处理。

5. 异步http请求线程

在XMLHttpRequest在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到JS引擎的处理队列中等待处理。

二、渲染过程

2.1 渲染流程

用户请求的HTML文本(text/html)通过浏览器的网络层到达渲染引擎后,渲染工作开始。每次通常渲染不会超过8K的数据块,其中基础的渲染流程图:

webkit引擎渲染的详细流程,其他引擎渲染流程稍有不同:

渲染流程有四个主要步骤:

  1. 解析HTML生成DOM树 – 渲染引擎首先解析HTML文档,生成DOM树
  2. 构建Render树 – 接下来不管是内联式,外联式还是嵌入式引入的CSS样式会被解析生成CSSOM树,根据DOM树与CSSOM树生成另外一棵用于渲染的树-渲染树(Render tree),
  3. 布局Render树 – 然后对渲染树的每个节点进行布局处理,确定其在屏幕上的显示位置
  4. 绘制Render树 – 最后遍历渲染树并用UI后端层将每一个节点绘制出来

以上步骤是一个渐进的过程,为了提高用户体验,渲染引擎试图尽可能快的把结果显示给最终用户。它不会等到所有HTML都被解析完才创建并布局渲染树。它会在从网络层获取文档内容的同时把已经接收到的局部内容先展示出来。

2.2 渲染细节

1. 生成DOM树

DOM树的构建过程是一个深度遍历过程:当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。DOM树的根节点就是document对象。

DOM树的生成过程中可能会被CSS和JS的加载执行阻塞,具体可以参见下一章。当HTML文档解析过程完毕后,浏览器继续进行标记为deferred模式的脚本加载,然后就是整个解析过程的实际结束触发DOMContentLoaded事件,并在async文档文档执行完之后触发load事件。

2. 生成Render树

生成DOM树的同时会生成样式结构体CSSOM(CSS Object Model)Tree,再根据CSSOM和DOM树构造渲染树Render Tree,渲染树包含带有颜色,尺寸等显示属性的矩形,这些矩形的顺序与显示顺序基本一致。从MVC的角度来说,可以将Render树看成是V,DOM树与CSSOM树看成是M,C则是具体的调度者,比HTMLDocumentParser等。

可以这么说,没有DOM树就没有Render树,但是它们之间不是简单的一对一的关系。Render树是用于显示,那不可见的元素当然不会在这棵树中出现了,譬如 <head>。除此之外,display等于none的也不会被显示在这棵树里头,但是visibility等于hidden的元素是会显示在这棵树里头的。

3. DOM树与Render树

DOM对象类型很丰富,什么head、title、div,而Render树相对来说就比较单一了,毕竟它的职责就是为了以后的显示渲染用嘛。Render树的每一个节点我们叫它渲染器renderer。

一棵Render树大概是酱紫,左边是DOM树,右边是Render树:

从上图我们可以看出,renderer与DOM元素是相对应的,但并不是一一对应,有些DOM元素没有对应的renderer,而有些DOM元素却对应了好几个renderer,对应多个renderer的情况是普遍存在的,就是为了解决一个renderer描述不清楚如何显示出来的问题,譬如有下拉列表的select元素,我们就需要三个renderer:一个用于显示区域,一个用于下拉列表框,还有一个用于按钮。

另外,renderer与DOM元素的位置也可能是不一样的。那些添加了 float或者 position:absolute的元素,因为它们脱离了正常的文档流,构造Render树的时候会针对它们实际的位置进行构造。

4. 布局与绘制

上面确定了renderer的样式规则后,然后就是重要的显示元素布局了。当renderer构造出来并添加到Render树上之后,它并没有位置跟大小信息,为它确定这些信息的过程,接下来是布局(layout)。

浏览器进行页面布局基本过程是以浏览器可见区域为画布,左上角为 (0,0)基础坐标,从左到右,从上到下从DOM的根节点开始画,首先确定显示元素的大小跟位置,此过程是通过浏览器计算出来的,用户CSS中定义的量未必就是浏览器实际采用的量。如果显示元素有子元素得先去确定子元素的显示信息。

布局阶段输出的结果称为box盒模型(width,height,margin,padding,border,left,top,…),盒模型精确表示了每一个元素的位置和大小,并且所有相对度量单位此时都转化为了绝对单位。

在绘制(painting)阶段,渲染引擎会遍历Render树,并调用renderer的 paint() 方法,将renderer的内容显示在屏幕上。绘制工作是使用UI后端组件完成的。

5. 回流与重绘

回流(reflow):当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染。reflow 会从 <html>这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开(实质上是元素的显示与隐藏)等,都将引起浏览器的 reflow。鼠标滑过、点击……只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。

重绘(repaint):改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

每次Reflow,Repaint后浏览器还需要合并渲染层并输出到屏幕上。所有的这些都会是动画卡顿的原因。Reflow 的成本比 Repaint 的成本高得多的多。一个结点的 Reflow 很有可能导致子结点,甚至父点以及同级结点的 Reflow 。在一些高性能的电脑上也许还没什么,但是如果 Reflow 发生在手机上,那么这个过程是延慢加载和耗电的。可以在csstrigger上查找某个css属性会触发什么事件。

reflow与repaint的时机:

  1. display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发生位置变化。
  2. 有些情况下,比如修改了元素的样式,浏览器并不会立刻 reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次 reflow,这又叫异步 reflow 或增量异步 reflow。
  3. 有些情况下,比如 resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

三、关键渲染路径与阻塞渲染

在浏览器拿到HTML、CSS、JS等外部资源到渲染出页面的过程,有一个重要的概念关键渲染路径(Critical Rendering Path)。例如为了保障首屏内容的最快速显示,通常会提到一个渐进式页面渲染,但是为了渐进式页面渲染,就需要做资源的拆分,那么以什么粒度拆分、要不要拆分,不同页面、不同场景策略不同。具体方案的确定既要考虑体验问题,也要考虑工程问题。了解原理可以让我们更好的优化关键渲染路径,从而获得更好的用户体验。

渐进式页面渲染:为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。(类似一个管道)

现代浏览器总是并行加载资源,例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。

同时,由于下面两点:

  1. CSS 被视为渲染 阻塞资源 (包括JS) ,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕,才会进行下一阶段。
  2. JavaScript 被认为是解释器阻塞资源,HTML解析会被JS阻塞,它不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。

存在阻塞的 CSS 资源时,浏览器会延迟 JavaScript 的执行和 DOM 构建。另外:

  1. 当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
  2. JavaScript 可以查询和修改 DOM 与 CSSOM。
  3. CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。

所以,script 标签的位置很重要。实际使用时,可以遵循下面两个原则:

  1. CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。
  2. JavaScript 应尽量少影响 DOM 的构建。

下面来看看 CSS 与 JavaScript 是具体如何阻塞资源的。

3.1 CSS

<style> p { color: red; }</style>
<link rel="stylesheet" href="index.css">

这样的 link 标签(无论是否 inline)会被视为阻塞渲染的资源,浏览器会优先处理这些 CSS 资源,直至 CSSOM 构建完毕。

渲染树(Render-Tree)的关键渲染路径中,要求同时具有 DOM 和 CSSOM,之后才会构建渲染树。即,HTML 和 CSS 都是阻塞渲染的资源。HTML 显然是必需的,因为包括我们希望显示的文本在内的内容,都在 DOM 中存放,那么可以从 CSS 上想办法。

最容易想到的当然是精简 CSS 并尽快提供它。除此之外,还可以用媒体类型(media type)和媒体查询(media query)来解除对渲染的阻塞。

<link href="index.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">

第一个资源会加载并阻塞。第二个资源设置了媒体类型,会加载但不会阻塞,print 声明只在打印网页时使用。第三个资源提供了媒体查询,会在符合条件时阻塞渲染。

关于CSS加载的阻塞情况:

  1. css加载不会阻塞DOM树的解析
  2. css加载会阻塞DOM树的渲染
  3. css加载会阻塞后面js语句的执行

没有js的理想情况下,html与css会并行解析,分别生成DOM与CSSOM,然后合并成Render Tree,进入Rendering Pipeline;但如果有js,css加载会阻塞后面js语句的执行,而(同步)js脚本执行会阻塞其后的DOM解析(所以通常会把css放在头部,js放在body尾)

3.2 JavaScript

JavaScript 的情况比 CSS 要更复杂一些。如果没有 defer 或 async,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的HTML元素之前,也就是说不等待后续载入的HTML元素,读到就加载并执行。观察下面的代码:

<p>Do not go gentle into that good night,</p>
<script>console.log("inline1")</script>
<p>Old age should burn and rave at close of day;</p>
<script src="app.js"></script>
<p>Rage, rage against the dying of the light.</p>
<script src="app.js"></script>
<p>Old age should burn and rave at close of day;</p>
<script>console.log("inline2")</script>
<p>Rage, rage against the dying of the light.</p>

这里的 script 标签会阻塞 HTML 解析,无论是不是 inline-script。上面的 P 标签会从上到下解析,这个过程会被两段 JavaScript 分别打断一次(加载、执行)。

解析过程中无论遇到的JavaScript是内联还是外链,只要浏览器遇到 script 标记,唤醒 JavaScript解析器,就会进行暂停 (blocked )浏览器解析HTML,并等到 CSSOM 构建完毕,才去执行js脚本。因为脚本中可能会操作DOM元素,而如果在加载执行脚本的时候DOM元素并没有被解析,脚本就会因为DOM元素没有生成取不到响应元素,所以实际工程中,我们常常将资源放到文档底部。

3.3 改变脚本加载次序defer与async

defer 与 async 可以改变之前的那些阻塞情形,这两个属性都会使 script 异步加载,然而执行的时机是不一样的。注意 async 与 defer 属性对于 inline-script 都是无效的,所以下面这个示例中三个 script 标签的代码会从上到下依次执行。

<script async>console.log("1")</script>
<script defer>console.log("2")</script>
<script>console.log("3")</script>

上面脚本会按需输出 1 2 3,故,下面两节讨论的内容都是针对设置了 src 属性的 script 标签。

先放个熟悉的图~

蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。

defer:

<script src="app1.js" defer></script>
<script src="app2.js" defer></script>
<script src="app3.js" defer></script>

defer 属性表示延迟执行引入 JavaScript,即 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,再触发 DOMContentLoaded(初始的 HTML 文档被完全加载和解析完成之后触发,无需等待样式表图像和子框架的完成加载) 事件 。

defer 不会改变 script 中代码的执行顺序,示例代码会按照 1、2、3 的顺序执行。所以,defer 与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。

async:

async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行,无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发(HTML解析完成事件)之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。

从上一段也能推出,多个 async-script 的执行顺序是不确定的,谁先加载完谁执行。值得注意的是,向 document 动态添加 script 标签时,async 属性默认是 true。

document.createElement:

使用 document.createElement 创建的 script 默认是异步的,示例如下。

console.log(document.createElement("script").async); // true

所以,通过动态添加 script 标签引入 JavaScript 文件默认是不会阻塞页面的。如果想同步执行,需要将 async 属性人为设置为 false。

如果使用 document.createElement 创建 link 标签会怎样呢?

const style = document.createElement("link");
style.rel = "stylesheet";
style.href = "index.css";
document.head.appendChild(style); // 阻塞?

其实这只能通过试验确定,已知的是,Chrome 中已经不会阻塞渲染,Firefox、IE 在以前是阻塞的,现在会怎样目前不太清楚。

四、优化渲染性能

结合渲染流程,可以针对性的优化渲染性能:

  1. 优化JS的执行效率
  2. 降低样式计算的范围和复杂度
  3. 避免大规模、复杂的布局
  4. 简化绘制的复杂度、减少绘制区域
  5. 优先使用渲染层合并属性、控制层数量
  6. 对用户输入事件的处理函数去抖动(移动设备)

这里主要参考Google的浏览器渲染性能的基础讲座,想看更详细内容可以去瞅瞅~

4.1 优化JS的执行效率

1. 动画实现使用requestAnimationFrame

setTimeout(callback)和setInterval(callback)无法保证callback函数的执行时机,很可能在帧结束的时候执行,从而导致丢帧,如下图:

requestAnimationFrame(callback)可以保证callback函数在每帧动画开始的时候执行。注意:jQuery3.0.0以前版本的animate函数就是用setTimeout来实现动画,可以通过jquery-requestAnimationFrame这个补丁来用requestAnimationFrame替代setTimeout

2. 长耗时的JS代码放到Web Workers中执行

JS代码运行在浏览器的主线程上,与此同时,浏览器的主线程还负责样式计算、布局、绘制的工作,如果JavaScript代码运行时间过长,就会阻塞其他渲染工作,很可能会导致丢帧。前面提到每帧的渲染应该在16ms内完成,但在动画过程中,由于已经被占用了不少时间,所以JavaScript代码运行耗时应该控制在3-4毫秒。如果真的有特别耗时且不操作DOM元素的纯计算工作,可以考虑放到Web Workers中执行。

var dataSortWorker = new Worker("sort-worker.js");
dataSortWorker.postMesssage(dataToSort);
 
// 主线程不受Web Workers线程干扰
dataSortWorker.addEventListener('message', function(evt) {
    var sortedData = e.data;
    // Web Workers线程执行结束
    // ...
});

3. 拆分操作DOM元素的任务,分别在多个frame完成

由于Web Workers不能操作DOM元素的限制,所以只能做一些纯计算的工作,对于很多需要操作DOM元素的逻辑,可以考虑分步处理,把任务分为若干个小任务,每个任务都放到 requestAnimationFrame中回调执行。

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
 
function processTaskList(taskStartTime) {
var nextTask = taskList.pop();
// 执行小任务
    processTask(nextTask);
if (taskList.length > 0) {
        requestAnimationFrame(processTaskList);
     }
}

4. 使用Chrome DevTools的Timeline来分析JavaScript的性能

打开 ChromeDevTools>Timeline>JSProfile,录制一次动作,然后分析得到的细节信息,从而发现问题并修复问题。

4.2 降低样式计算的范围和复杂度

添加或移除一个DOM元素、修改元素属性和样式类、应用动画效果等操作,都会引起DOM结构的改变,从而导致浏览器要repaint或者reflow。那么这里可以采取一些措施。

1. 降低样式选择器的复杂度

尽量保持class的简短,或者使用Web Components框架。

.box:nth-last-child(-n+1) .title {}
// 改善后
.final-box-title {}

2. 减少需要执行样式计算的元素个数

由于浏览器的优化,现代浏览器的样式计算直接对目标元素执行,而不是对整个页面执行,所以我们应该尽可能减少需要执行样式计算的元素的个数。

4.3 避免大规模、复杂的布局

布局就是计算DOM元素的大小和位置的过程,如果你的页面中包含很多元素,那么计算这些元素的位置将耗费很长时间。布局的主要消耗在于:1. 需要布局的DOM元素的数量;2. 布局过程的复杂程度

1. 尽可能避免触发布局

当你修改了元素的属性之后,浏览器将会检查为了使这个修改生效是否需要重新计算布局以及更新渲染树,对于DOM元素的几何属性修改,比如width/height/left/top等,都需要重新计算布局。对于不能避免的布局,可以使用Chrome DevTools工具的Timeline查看布局的耗时,以及受影响的DOM元素数量。

2. 使用flexbox替代老的布局模型

老的布局模型以相对/绝对/浮动的方式将元素定位到屏幕上,而Floxbox布局模型用流式布局的方式将元素定位到屏幕上。通过一个小实验可以看出两种布局模型的性能差距,同样对1300个元素布局,浮动布局耗时14.3ms,Flexbox布局耗时3.5ms。IE10+支持。

3. 避免强制同步布局事件的发生

根据渲染流程,JS脚本是在layout之前执行,但是我们可以强制浏览器在执行JS脚本之前先执行布局过程,这就是所谓的强制同步布局。

requestAnimationFrame(logBoxHeight);
// 先写后读,触发强制布局
function logBoxHeight() {
// 更新box样式
    box.classList.add('super-big');
 
// 为了返回box的offersetHeight值
// 浏览器必须先应用属性修改,接着执行布局过程
    console.log(box.offsetHeight);
}
 
// 先读后写,避免强制布局
function logBoxHeight() {
// 获取box.offsetHeight
    console.log(box.offsetHeight);
// 更新box样式
    box.classList.add('super-big');
}

在JS脚本运行的时候,它能获取到的元素样式属性值都是上一帧画面的,都是旧的值。因此,如果你在当前帧获取属性之前又对元素节点有改动,那就会导致浏览器必须先应用属性修改,结果执行布局过程,最后再执行JS逻辑。

4. 避免连续的强制同步布局发生

如果连续快速的多次触发强制同步布局,那么结果更糟糕。比如下面的例子,获取box的属性,设置到paragraphs上,由于每次设置paragraphs都会触发样式计算和布局过程,而下一次获取box的属性必须等到上一步设置结束之后才能触发。

function resizeWidth() {
// 会让浏览器陷入'读写读写'循环
for (var i = 0; i < paragraphs.length; i++)
        paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
 
// 改善后方案
var width = box.offsetWidth;
function resizeWidth() {
for (var i = 0; i < paragraphs.length; i++)
        paragraphs[i].style.width = width +px';
     }
}

注意:可以使用FastDOM来确保读写操作的安全,从而帮你自动完成读写操作的批处理,还能避免意外地触发强制同步布局或快速连续布局,消除大量操作DOM的时候的布局抖动。

4.4 简化绘制的复杂度、减少绘制区域

Paint就是填充像素的过程,通常这个过程是整个渲染流程中耗时最长的一环,因此也是最需要避免发生的一环。如果Layout被触发,那么接下来元素的Paint一定会被触发。当然纯粹改变元素的非几何属性,也可能会触发Paint,比如背景、文字颜色、阴影效果等。

1. 提升移动或渐变元素的绘制层

绘制并非总是在内存中的单层画面里完成的,实际上,浏览器在必要时会将一帧画面绘制成多层画面,然后将这若干层画面合并成一张图片显示到屏幕上。这种绘制方式的好处是,使用transform来实现移动效果的元素将会被正常绘制,同时不会触发其他元素的绘制。

2. 减少绘制区域,简化绘制的复杂度

浏览器会把相邻区域的渲染任务合并在一起进行,所以需要对动画效果进行精密设计,以保证各自的绘制区域不会有太多重叠。另外可以实现同样效果的不同方式,应该采用性能更好的那种。

3. 通过Chrome DevTools来分析绘制复杂度和时间消耗,尽可能降低这些指标

打开DevTools,在弹出的面板中,选中 MoreTools>Rendering选项卡下的Paint flashing,这样每当页面发生绘制的时候,屏幕就会闪现绿色的方框。通过该工具可以检查Paint发生的区域和时机是不是可以被优化。通过Chrome DevTools中的 Timeline>Paint选项可以查看更细节的Paint信息

4.5 优先使用渲染层合并属性、控制层数量

1. 使用transform/opacity实现动画效果

使用transform/opacity实现动画效果,会跳过渲染流程的布局和绘制环节,只做渲染层的合并。

Type Func
Position transform: translate(-px,-px)
Scale transform: scale(-)
Rotation transform: rotate(-deg)
Skew transform: skew(X/Y)(-deg)
Matrix transform: matrix(3d)(..)
Opacity opacity: 0-1

使用transform/opacity的元素必须独占一个渲染层,所以必须提升该元素到单独的渲染层。

2. 提升动画效果中的元素

应用动画效果的元素应该被提升到其自有的渲染层,但不要滥用。在页面中创建一个新的渲染层最好的方式就是使用CSS属性will-change,对于目前还不支持will-change属性、但支持创建渲染层的浏览器,可以通过3D transform属性来强制浏览器创建一个新的渲染层。需要注意的是,不要创建过多的渲染层,这意味着新的内存分配和更复杂的层管理。注意,IE11,Edge17都不支持这一属性。

.moving-element {
    will-change: transform;
    transform: translateZ(0);
}

3. 管理渲染层、避免过多数量的层

尽管提升渲染层看起来很诱人,但不能滥用,因为更多的渲染层意味着更多的额外的内存和管理资源,所以当且仅当需要的时候才为元素创建渲染层。

* {
  will-change: transform;
  transform: translateZ(0);
}

4. 使用Chrome DevTools来了解页面的渲染层情况

开启 Timeline>Paint选项,然后录制一段时间的操作,选择单独的帧,看到每个帧的渲染细节,在ESC弹出框有个Layers选项,可以看到渲染层的细节,有多少渲染层,为何被创建?

4.6 对用户输入事件的处理函数去抖动(移动设备)

用户输入事件处理函数会在运行时阻塞帧的渲染,并且会导致额外的布局发生。

1. 避免使用运行时间过长的输入事件处理函数

理想情况下,当用户和页面交互,页面的渲染层合并线程将接收到这个事件并移动元素。这个响应过程是不需要主线程参与,不会导致JavaScript、布局和绘制过程发生。但是如果被触摸的元素绑定了输入事件处理函数,比如touchstart/touchmove/touchend,那么渲染层合并线程必须等待这些被绑定的处理函数执行完毕才能执行,也就是用户的滚动页面操作被阻塞了,表现出的行为就是滚动出现延迟或者卡顿。

简而言之就是你必须确保用户输入事件绑定的任何处理函数都能够快速的执行完毕,以便腾出时间来让渲染层合并线程完成他的工作。

2. 避免在输入事件处理函数中修改样式属性

输入事件处理函数,比如scroll/touch事件的处理,都会在requestAnimationFrame之前被调用执行。因此,如果你在上述输入事件的处理函数中做了修改样式属性的操作,那么这些操作就会被浏览器暂存起来,然后在调用requestAnimationFrame的时候,如果你在一开始就做了读取样式属性的操作,那么将会触发浏览器的强制同步布局操作。

3. 对滚动事件处理函数去抖动

通过requestAnimationFrame可以对样式修改操作去抖动,同时也可以使你的事件处理函数变得更轻。

function onScroll(evt) {
    // Store the scroll value for laterz.
    lastScrollY = window.scrollY;
    // Prevent multiple rAF callbacks.
    if (scheduledAnimationFrame) {
        return;
    }
    scheduledAnimationFrame = true;
    requestAnimationFrame(readAndUpdatePage);
}
window.addEventListener('scroll', onScroll);

作者 | SHERlocked93

网站动态换肤

2021
21/03

各位如果为自己的网站动态的换肤是怎么操作的?

一般动态更新<style>标签内的样式字符串,使用CSS变量实现全局控制。<style>标签中只传入变量,在具体的样式中使用var(--aaa)引入。

今天看到一个挺好的方法,到时可以试试。

这个方法是借助rel属性的alternate值(候补属性)实现。

<link href="reset.css" rel="stylesheet" type="text/css"> 
<link href="default.css" rel="stylesheet" type="text/css" title="默认"> 
<link href="red.css" rel="alternate stylesheet" type="text/css" title="红色"> 
<link href="green.css" rel="alternate stylesheet" type="text/css" title="绿色">
上面这四个<link>元素,略有不同,涉及到阻塞渲染css的问题。
  • 没有title属性,rel属性值仅仅是stylesheet的默认会加载并渲染,如reset.css;
  • 有title属性,rel属性值仅仅是stylesheet的作为默认样式CSS文件加载并渲染,如default.css;
  • 有title属性,rel属性值同时包含alternate stylesheet的作为备选样式CSS文件加载,默认不渲染,如red.css和green.css;

另外,<link>标签添加disabled属性,也会阻止其渲染,可以手动改变其值:link.disabled = truelink.disabled = false

可以看下如何实现一个换肤:

<input id="default" type="radio" name="skin" value="default.css" checked>
<input id="red" type="radio" name="skin" value="red.css">
<input id="green" type="radio" name="skin" value="green.css">

<script>
var eleLinks = document.querySelectorAll('link[title]');
var eleRadios = document.querySelectorAll('[type="radio"]');
[].slice.call(eleRadios).forEach(function (radio) {
    radio.addEventListener('click', function () {
        var value = this.value;
        [].slice.call(eleLinks).forEach(function (link) {
            link.disabled = true;
            if (link.getAttribute('href') == value) {
                // 该样式CSS文件生效
                link.disabled = false;
            }
        });
    });
});
</script>

DOM之事件定义-Event类

2021
21/03

Event

什么是Event?就是表示在DOM中发生的事件。

我们知道事件可以被用户的鼠标点击的类似操作给触发,也可以通过程序的HTMLElement.click()类似的方式来触发。当然,我们也可以定义事件,然后通过EventTarget.dispatchEvent()来触发。

要创建一个事件,首先可以根据Event构造函数来创建一个事件。

var event = new Event('build');

elem.addEventListener('build', function (e) { /* ... */ }, false);

// 匹配事件.
elem.dispatchEvent(event);
这个兼容性也还行,支持大多数的现代性的浏览器,当然了,IE不支持。要想使用IE支持,就要使用后面提到的老式的方法。

添加自定义数据 CustomEvent()

为了添加更多的数据到事件对象,CustomEvent接口有一个detail属性可以传递自定义数据。

我们来试一试:

var event = new CustomEvent('build', { detail: 'xiaohesong' });
这样就可以访问一些其他数据了。
function eventHandler(e) { 
    console.log('The name is: ' + e.detail); 
}

老式的方法

老的创建事件的方法是受Java启发产生的API。下面是一个例子:

// 创建一个事件,注意:`Event`这个参数是.
var event = document.createEvent('Event');

// 定义事件的名字是 'build'.
event.initEvent('build', true, true);

// 监听事件.
elem.addEventListener('build', function (e) {
  // e.target 匹配元素
}, false);

// target可以是任何Element或者其他的EventTarget.
elem.dispatchEvent(event);
可以发现,比较啰嗦,但是如果你要做兼容,似乎只能使用这个法子。

值得说一下的是,上面创建的event对象,就是被创建的Event对象。

然后对应创建事件的类型,就是上面那个注意的'Event'参数。

var event = document.createEvent(type);

type是表示要创建的事件类型,一个字符串。可能包含的有"UIEvents","MouseEvents""MutationEvents", 和 "HTMLEvents"

事件冒泡

有时候我们使用事件冒泡法则来完成事件代理。

来看一个小?:

<form>
  <textarea></textarea>
</form>
const form = document.querySelector('form');
const textarea = document.querySelector('textarea');

// 创建了一个新的事件,允许冒泡,并且可以通过传递任何数据给details来得到你想要的。

const eventAwesome = new CustomEvent('awesome', {
  bubbles: true,
  detail: { text: () => textarea.value }
});

// form元素监听自定义的"awesome"事件,然后输出
form.addEventListener('awesome', e => console.log(e.detail.text()));

// 当用户输入内容时,form里的textarea 调度/触发(dispatches/triggers)事件以触发,并将自身用作起点
textarea.addEventListener('input', e => e.target.dispatchEvent(eventAwesome));

这个小?完成下来,是不是感觉也很容易记住的。

动态创建和分派事件

元素可以监听还没有创建的事件,我们来一睹为快。

<form>
  <textarea></textarea>
</form>
const form = document.querySelector('form');
const textarea = document.querySelector('textarea');

form.addEventListener('awesome', e => console.log(e.detail.text()));

textarea.addEventListener('input', function() {
  // 动态创建和调度/触发事件
  // 注意:我们选择使用的是”函数表达式”(而不是“箭头函数表达式”),因此“this”将可以表示此元素。
  this.dispatchEvent(new CustomEvent('awesome', { bubbles: true, detail: { text: () => textarea.value } }))
});

好吧,还是和之前差不多是不,只是把创建事件放在了dispatchEvent上面而已;

触发内置事件

来吧,直接看一个?,吃了它:

function simulateClick() {
  var event = new MouseEvent('click', {
    view: window,
    bubbles: true,
    cancelable: true
  });
  var cb = document.getElementById('checkbox'); 
  var cancelled = !cb.dispatchEvent(event);
  if (cancelled) {
    // A handler called preventDefault.
    alert("cancelled");
  } else {
    // None of the handlers called preventDefault.
    alert("not cancelled");
  }
}

Vue重点知识总结—vue-router

2021
12/03

1.vue-router导航守卫(生命周期钩子)

导航守卫主要⽤来对路由的跳转进⾏监控,控制它的跳转或取消,路由守卫有全局的, 单个路由独享的, 或者组件级的。

导航钩⼦有3个参数

  • to:即将要进⼊的⽬标路由对象;
  • from:当前导航即将要离开的路由对象;
  • next:调⽤该⽅法后,才能进⼊下⼀个钩⼦函数(afterEach)。

具体有哪些钩子

  1. 全局前置守卫:router.beforeEach
  2. 全局解析守卫:router.beforeResolve
  3. 全局后置钩子:router.afterEach
  4. 路由独享钩子:在路由配置中添加beforeEnter钩子
  5. 组件内钩子:beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave

简单理解 beforeEach

每次通过跳转路由时,都会触发beforeEach这个钩⼦函数,这个回调函数共有三个参数,to,from,next这三个参数,to表⽰我要跳转的⽬标路由对应的参数,from表⽰来⾃那个路由,就是当前导航即将要离开的路由对象,next是⼀个回调函数,⼀定要调⽤next⽅法来resolve这个钩⼦函数,否则无法进⼊下⼀个钩⼦函数,也无法加载路由。

详见文档

2.导航守卫解析流程

  1. 导航被触发。
  2. 在失活的组件里调用 beforeRouteLeave 守卫。
  3. 调用全局的 beforeEach 守卫。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
  5. 在路由配置里调用 beforeEnter
  6. 解析异步路由组件。
  7. 在被激活的组件里调用 beforeRouteEnter
  8. 调用全局的 beforeResolve 守卫 (2.5+)。
  9. 导航被确认。
  10. 调用全局的 afterEach 钩子。
  11. 触发 DOM 更新。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。

3.路由懒加载如何实现,原理是什么?

路由懒加载需要导入异步组件,最常用的是通过import()来实现它。

function load(component) {
    return () => import(`views/${component}`)
}
// 箭头函数
const asyncPage = () => import('./views/home.vue')
// import()函数需要作为返回值,其返回Promise

编译打包后,会把每个路由组件的代码分割成一个js文件,初始化时不会加载这些js文件,只当激活路由时(访问该路由时)才会去按需加载对应的路由组件js文件。可以加快项⽬的加载速度。

在Vue3中,在组件中使用异步组件时需要使用defineAsyncComponent()函数才能实现懒加载,但在vue-router中同样可以使用import()方法

这时由于在 Vue 3 中,函数组件被定义为纯函数,异步组件定义需要通过将其包装在一个新的 defineAsyncComponent helper 中来显式定义。

import { defineAsyncComponent } from 'vue'
const home = defineAsyncComponent(() => import('@/views/home.vue'))
export default {
  name: 'async-components',
  components:{
    home
  }
};

4.vue-router如何做用户登录权限?

第一次打开页面或刷新页面时候可以在路由前置钩子router.beforeEach中查询用户信息。再根据返回的用户信息和to,from参数判断登录权限(这里要把用户的信息和登录状态做一个缓存),然后使用next回调控制路由跳转方向。具体的代码要根据具体的项目进行实施。

不是第一次打开页面情况,跳转时直接判断缓存中的登录状态和用户信息来判断是否拥有权限。若有权限则继续跳转到指定的路由。

5. vue-router 3.1.0 <router-link>新增的v-slot属性怎么⽤?

router-link 通过⼀个作⽤域插槽暴露底层的定制能⼒。方便我们更自由的定制导航链接的形式。记得把 custom 配置传递给 <router-link>,以防止它将内容包裹在 <a> 元素内。

详见文档

6. <router-view> 的 v-slot

详见文档

7. 如何实现⼀个路径渲染多个组件?

可以通过命名视图(router-view),它容许同⼀界⾯中拥有多个单独命名的视图,⽽不是只有⼀个单独的出⼝。如果router-view 没有设置名字,那么默认为default

<router-view class="view left-sidebar" name="LeftSidebar"></router-view>
<router-view class="view main-content"></router-view>
<router-view class="view right-sidebar" name="RightSidebar"></router-view>

一个视图使用一个组件渲染,因此对于同个路由,多个视图就需要多个组件。在配置routes时,确保正确使用 components 配置 (带上 s):

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    {
      path: '/',
      components: {
        default: Home,
        // LeftSidebar: LeftSidebar 的缩写
        LeftSidebar,
        // 它们与 `<router-view>` 上的 `name` 属性匹配
        RightSidebar,
      },
    },
  ],
})

详见文档

8. 如何实现多个路径共享⼀个组件?

只需将多个路径的component字段的值设置为同⼀个组件即可。

9. 如何监测动态路由的变化

可以通过watch⽅法来对$route进⾏监听,或者通过导航守卫的钩⼦函数beforeRouteUpdate来监听它的变化。

在Vue3中可使用useRoute()onBeforeRouteUpdate

10. Vue 如何去除url中的 #

哈希字符(#)部分 URL 从未被发送到服务器,所以它不需要在服务器层面上进行任何特殊处理。不过,它在 SEO 中确实有不好的影响。

如果你担心这个问题,可以使用 HTML5 模式,将路由模式改为history

路由切换时本质是向history栈中添加一个路由,也就是添加一个history记录。

vue2(vue-router 3.x,已经弃用):

const router = new VueRouter({
  mode: 'history', // 也可设为 hash
  routes: [...]
})

vue3(vue-router 4.x):

import { createRouter, createWebHashHistory } from 'vue-router'

// hash 模式是用 createWebHashHistory() 创建的:
const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    //...
  ],
})

// 用 createWebHistory() 创建 HTML5 模式,推荐使用这个模式:
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    //...
  ],
})

11. $route 和 $router 的区别

$route是用来获取路由信息的:

它是当前路由信息的⼀个对象,⾥⾯包含路由的⼀些基本属性,包括name(路由名称)、meta(配置的路由元信息)、path(当前路径)、hash(hash参数)、query(查询参数)、params(当前传递参数)、fullPath(当前全路径)、matched(匹配项)等等。

每一个路由都会有一个$route对象,是一个局部的对象。

$router是用来操作路由的:

$router是VueRouter的一个实例,他包含了所有的路由,以及路由的跳转方法,钩子函数等,也包含一些子对象(例如history)。

12. Vue-router 使⽤params与query传参有什么区别

⽤法上

query要⽤path来引⼊,params要⽤name来引⼊,接收参数都是类似的,分别是this.$route.query.namethis.$route.params.name

// 命名的路由,并加上参数,让路由建立 url
router.push({ name: 'user', params: { username: 'eduardo' } })

// 带查询参数,结果是 /register?plan=private
router.push({ path: '/register', query: { plan: 'private' } })

// `params` 不能与 `path` 一起使用,这时,params无法传递
router.push({ path: '/user', params: { username } }) // -> /user

// 占位符传参时,params参数对应的字段会被拼接到path中
// 使用 `name` 和 `params` 从自动 URL 编码中获益
const routes = [
  { name: 'user',path: '/user/:username' },
]
router.push({ name: 'user', params: { username } }) // -> /user/eduardo

展⽰上

  • query更加类似于我们ajax中get传参,params则类似于post,说的再简单⼀点,前者在浏览器地址栏中显⽰参数,后者则不显⽰
  • params是路由的⼀部分,必须要有,组件才能正常执行。query是拼接在url后⾯的参数,若没有也可正常执行组件。
  • query不设置也可以通过浏览器url进行传参,params不设置的时候,刷新页⾯或者返回参数会丢失
  • 刷新页面后,通过params传参会出现参数丢失的情况,可以通过query的传参⽅式或者在路由匹配规则加⼊占位符即可以解决参数丢失的情况。

13. Vue路由实现的底层原理

Vue的路由实现:hash 模式和 history 模式

hash模式:

早期前端路由的实现是基于window.location.hash 来实现的,window.location.hash 的值就是 URL中#后面的内容。

特点:hash虽然在URL中,但不被包括在HTTP请求中;只是用来指导浏览器动作,对服务端无作用,且hash的切换不会重加载页面。

hash 模式下,仅 # 号之前的内容会被包含在请求中,因此对于后端来说,即使路由切换错误,也不会返回 404 。

但是 hash 模式有两个缺点:

  1. url 不太美观
  2. 对SEO不太友好

history模式:

history采用HTML5的新特性;且提供了两个新方法:history.pushState()history.repalceState()可以对浏览器历史记录栈内存进行修改,且页面不会重载。

history.pushState方法接受三个参数,依次为:

  • state:一个与指定网址相关的状态对象,popstate事件触发时,该对象会传入回调函数。如果不需要这个对象,此处可以填null。
  • title:新页面的标题,但是所有浏览器目前都忽略这个值,因此这里可以填null。
  • url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

假定当前网址是example.com/1.html,我们使用pushState方法在浏览记录(history对象)中添加一个新记录。

var stateObj = { foo: 'bar' };
history.pushState(stateObj, 'page 2', '2.html');

这样地址栏就可以看到我们自己存的标识了。历史记录也就存进去了。

每当激活的历史记录发生变化时,都会触发popstate事件,调用history.pushState()或者history.replaceState()不会触发popstate事件。pushState事件只会在其他浏览器操作时触发, 比如点击后退按钮(或者在JavaScript中调用history.back()history.go()方法)。

window.addEventListener('popstate', function(event) {
  //做一些操作
  console.log(event.state) // history.pushState()中传入的state状态对象
});

缺点:若没有对服务器(如nginx)进行配置,刷新本不存在的(pushState新增的历史记录)url页面时会出现404状况。

SPA单页面应用和SSR服务端渲染对比

2021
10/03

1.什么是单页面应用(SPA)?

整个web项目只有一个页面,使用路由机制进行组件之间的切换。

优点:客户端渲染、数据传输量小、减少服务器端压力、交互/响应速度快、前后端完全分离。

缺点:首屏加载慢、对SEO不友好,不利于搜索引擎收录和排名。

2.什么是服务端渲染(SSR)?

将组件或页面通过服务器端生成HTML字符串,再将整体页面数据发送到浏览器端渲染。

优点:对于SEO友好、首屏加载速度快。

缺点:页面重复加载次数高、开发效率低、数据传输量大、服务器压力大。

3.SPA、SSR分别适合什么样的应用场景?

SPA:对项目性能要求高、页面加载速度快、要求客户端渲染、对SEO要求低。

SSR:对项目SEO要求高、首次打开响应速度快。

4.SPA与SSR本质区别是什么?

1.传输数据不同:

SPA与服务器通过API接口拿到局部数据(json对象),然后由客户端拼接为html进行渲染。

SSR直接请求页面拿到整个页面的html进行渲染。

2.SEO优化问题:

SPA在客户端源代码中无法看到动态渲染的html,无法被爬虫爬到。而SSR可以在源代码看到整个页面所有html数据。