React 基础及核心总结/一

2022
24/08

1. 元素属性及props

1)组件可以接受任意 props,包括基本数据类型,React 元素以及函数。

2)组件接到父组件传来的props是只读的,绝不能够被修改的。这也叫做单向数据流。

3)组件中的标签元素与原生的dom元素不同,其会被转义为对象(虚拟dom)。组件元素的属性与事件与原生的也不尽相同。比如class 在 JSX 中被写作 classNameonclick 在 JSX 中被写作 onClickfor 在 JSX 中被写作 htmlFor。这些新构建的属性和事件一般都是用小驼峰的书写形式。

4)不要将 props “镜像”给 state,请考虑直接使用 props。否则可能会产生BUG(当一个派生 state 值被 setState 方法更新时,你的prop是否需要更新?)。

5)属性可使用展开表达式将所有props一次性赋值:

function App1() {
  return <Greeting firstName="Ben" lastName="Hector" />;
}
// 等价于
function App2() {
  const props = {firstName: 'Ben', lastName: 'Hector'};
  return <Greeting {...props} />;
}

6)属性可以条件赋值

const Button = props => {
  const { kind, ...other } = props;
  const className = kind === "primary" ? "PrimaryButton" : "SecondaryButton";
  return <button className={className} {...other} />;
};

const App = () => {
  return (
    <div>
      <Button kind="primary" onClick={() => console.log("clicked!")}>
        Hello World!
      </Button>
    </div>
  );
};

2. state 和 setState

1)不要直接修改 State,直接修改将不会重新渲染组件,而是应该使用 setState(),类似于小程序中的setData()。如this.state.comment = 'Hello';是错误的。这里也能看出,React的数据是单向绑定且单向流动。

2)Class组件中,构造函数(constructor)是唯一能够直接设置State的地方。但现在一般都使用函数式组件及hook方法取代Class组件了。

3)Class组件中,setState方法会浅合并对象(Object.assign)。而在hook方法中,useState 导出的set方法不会自动合并更新对象。你可以用函数式的 setState 结合展开运算符来达到合并更新对象的效果。

const [state, setState] = useState({});
setState(prevState => {
  // 也可以使用 Object.assign
  return {...prevState, ...updatedValues};
});

3. Refs 和 Dom

1)Refs 创建

在React中也有refs,和Vue等其他框架一样,同样在标签中使用ref属性。Refs 允许我们访问 DOM 节点或在 render 方法中创建的 React 元素。但非必要尽量避免能够使用props解决的问题而使用refs

在Class组件中使用React.createRef()来创建ref,或在函数式组件内部使用名为useRef()的hook来创建ref。一般情况下可以使用ref的current属性来访问该元素的dom节点或该自定义组件的实例。不可在函数组件父级中对函数组件使用ref,因为函数组件没有实例。

function MyFunctionComponent() {
   // 这里必须声明 textInput,这样 ref 才可以引用它
  const textInput = useRef(null);

  function handleClick() {
    textInput.current.focus();
  }
  
  // 这样做是正确的(在函数组件内部访问元素dom)
  return (
    <div>
      <input
        type="text"
        ref={textInput} />
      <input
        type="button"
        value="Focus the text input"
        onClick={handleClick}
      />
    </div>
  );
}

class Parent extends React.Component {
  constructor(props) {
    super(props);
    this.textInput = React.createRef();
  }
  render() {
    // 这样做是错误的,无法访问的(在函数组件外部访问组件实例)
    return (
      <MyFunctionComponent ref={this.textInput} />
    );
  }
}

useRef() 比 ref 属性更有用。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。

这是因为它创建的是一个普通 Javascript 对象。而 useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。

请记住,当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。如果想要在 React 绑定或解绑 DOM 节点的 ref 时运行某些代码,则需要使用回调 ref 来实现。

2)回调 Refs

你可以在组件间传递回调形式的 Refs,也就是使用回调函数的形式创建Refs,这个函数中接受 React 组件实例或 HTML DOM 元素作为参数,以使它们能在其他地方被存储和访问。比如将回调函数通过props传递给子组件,这样父子组件都可以访问到子组件中的元素。

React 将在组件挂载时,会调用 ref 回调函数并传入 DOM 元素,当卸载时调用它并传入 null。在 componentDidMount 或 componentDidUpdate 触发前,React 会保证 refs 一定是最新的。

回调Refs创建和使用

class CustomTextInput extends React.Component {
  constructor(props) {
    super(props);

    this.textInput = null;

    this.setTextInputRef = element => {
      this.textInput = element;
    };

    this.focusTextInput = () => {
      // 使用原生 DOM API 使 text 输入框获得焦点
      if (this.textInput) this.textInput.focus();
    };
  }

  componentDidMount() {
    // 组件挂载后,让文本框自动获得焦点
    this.focusTextInput();
  }

  render() {
    // 使用 `ref` 的回调函数将 text 输入框 DOM 节点的引用存储到 React
    // 实例上(比如 this.textInput)
    return (
      <div>
        <input
          type="text"
          ref={this.setTextInputRef}
        />
        <input
          type="button"
          value="Focus the text input"
          onClick={this.focusTextInput}
        />
      </div>
    );
  }
}

父子组件共享Refs

function CustomTextInput(props) {
  return (
    <div>
      <input ref={props.inputRef} />
    </div>
  );
}

class Parent extends React.Component {
  render() {
    return (
      <CustomTextInput
        inputRef={el => this.inputElement = el}
      />
    );
  }
}

在函数式组件中可以使用useCallback()来创建具有变化监听的回调Ref

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}
// 没有选择使用 useRef,因为当 ref 是一个对象时它并不会把当前 ref 的值的变化通知到我们。
// 注意到我们传递了 [] 作为 useCallback 的依赖列表。这确保了 ref callback 不会在再次渲染时改变,因此 React 不会在非必要的时候调用它。
// 在此示例中,当且仅当组件挂载和卸载时,callback ref 才会被调用,因为渲染的 <h1> 组件在整个重新渲染期间始终存在。
// 如果你希望在每次组件调整大小时都收到通知,则可能需要使用 ResizeObserver 或基于其构建的第三方 Hook。

4. 元素中表达式渲染的一些问题

falsenullundefined, and true 是合法的子元素。但它们并不会被渲染。以下的 JSX 表达式渲染结果相同:

<div />

<div></div>

<div>{false}</div>

<div>{null}</div>

<div>{undefined}</div>

<div>{true}</div>

值得注意的是有一些 “falsy” 值(被类型转义),如数字 0,仍然会被 React 渲染。这个特点和其他的一些框架会有些出入。

例如,以下代码并不会像你预期那样工作,因为当 props.messages 是空数组时,将会渲染为数字 0

// 如下会被渲染为数字0
<div>
  {props.messages.length &&
    <MessageList messages={props.messages} />
  }
</div>

// 需要渲染右侧组件,&& 之前的表达式应总是布尔值:
<div>
  {props.messages.length > 0 &&
    <MessageList messages={props.messages} />
  }
</div>

Pinia是Vuex的良好替代品吗?

2022
25/07

介绍

Pinia 是西班牙语中菠萝发音,菠萝实际上是一组单独的花朵,它们结合在一起形成多个水果。与商店类似,每一家都是独立诞生的,但最终都是相互联系的。

Pinia 是 Vue.js 的轻量级状态管理库,最近很受欢迎。它使用 Vue 3 中的新反应系统来构建一个直观且完全类型化的状态管理库。

Pinia的成功可以归功于其管理存储数据的独特功能(可扩展性、存储模块组织、状态变化分组、多存储创建等)。

另一方面,Vuex也是为Vue框架建立的一个流行的状态管理库,它也是Vue核心团队推荐的状态管理库。Vuex高度关注应用程序的可扩展性、开发人员的工效和信心。它基于与Redux相同的流量架构。

Pinia 设置

Pinia 很容易上手,因为它只需要安装和创建一个store。

要安装 Pinia,您可以在终端中运行以下命令:

yarn add pinia@next 
# or with npm 
npm install pinia@next 

该版本与Vue 3兼容,如果你正在寻找与Vue 2.x兼容的版本,请查看v1分支。

Pinia是一个围绕Vue 3 Composition API的封装器。因此,你不必把它作为一个插件来初始化,除非你需要Vue devtools支持、SSR支持和webpack代码分割的情况:

//app.js 
import { createPinia } from 'pinia' 
app.use(createPinia()) 

在上面的片段中,你将Pinia添加到Vue.js项目中,这样你就可以在你的代码中使用Pinia的全局对象。这一步不可省略。

为了创建一个store,你用一个包含创建一个基本store所需的states、actions和getters的对象来调用 defineStore 方法。

import { defineStore } from 'pinia'

// main是Store的名称。它在您的应用程序中是唯一的。
// 并将出现在DevTools中
export const useMainStore = defineStore('main', {
  // a function that returns a fresh state
  state: () => ({
    counter: 0,
    name: 'Eduardo',
  }),
  // optional getters
  getters: {
    // getters receive the state as first parameter
    doubleCount: (state) => state.counter * 2,
    // use getters in other getters
    doubleCountPlusOne(): number {
      return this.doubleCount + 1
    },
  },
  // optional actions
  actions: {
    reset() {
      // `this` is the store instance
      this.counter = 0
    },
  },
})

Pinia使用

使用 Pinia,可以按如下方式访问该store:

import { useMainStore } from '@/stores/main'
import { storeToRefs } from 'pinia'

export default defineComponent({ 
  setup() { 
    const main = useMainStore() 

    // extract specific store properties
    const { counter, doubleCount } = storeToRefs(main)
 
    return { 
      // whole store
      main,
      // state or getter
      counter,
      doubleCount,
      // 计算属性方式 
      _counter: computed(() => main.counter), 
    } 
  }, 
}) 

请注意,在访问其属性时省略了 store 的 state 对象。

文档

要了解有关 Pinia 的更多信息,请查看其文档

Vuex 和 Pinia 对比

Pinia试图尽可能地接近Vuex的理念。它的设计是为了测试Vuex的下一次迭代的建议,它是成功的,因为目前有一个开放的RFC,用于Vuex 5,其API与Pinea使用的非常相似。这个项目的个人意图是重新设计使用全局Store的体验,同时保持Vue的平易近人的理念。Pinea的API与Vuex一样接近,因为它不断向前发展,使人们很容易迁移到Vuex,甚至在未来融合两个项目(在Vuex下)。

尽管 Pinia 足以取代 Vuex,但取代 Vuex 并不是它的目标,因此 Vuex 仍然是 Vue.js 应用程序的推荐状态管理库。

在现阶段,由于Pinia是轻量级的,体积很小,它适合于中小型应用。它也适用于低复杂度的Vue.js项目,因为一些调试功能,如时间旅行和编辑仍然不被支持。

将 Vuex 用于中小型 Vue.js 项目是过度的,因为它是重量级的,对性能有很大影响。因此,Vuex 适用于大规模、高复杂度的 Vue.js 项目。

Ps:2022-8:看官网的说明,意思是Pinia将会取代Vuex。

这些是目前Pinia相比Vuex的优势。

  • 极其轻巧(体积约 1KB)
  • store 的 action 被调度为常规的函数调用,而不是使用 dispatch 方法或 MapAction 辅助函数,这在 Vuex 中很常见。
  • 支持多个Store。
  • 支持 Vue devtools、SSR 和 webpack 代码拆分。
  • 无需创建自定义的复杂包装器来支持 TypeScript,所有内容都是类型化的,并且 API 的设计方式尽可能地利用 TS 类型推断。与在 Vuex 中添加 TypeScript 相比,添加 TypeScript 更容易。
  • Pinia 不支持嵌套存储。相反,它允许你根据需要创建store。但是,store仍然可以通过在另一个store中导入和使用store来隐式嵌套
  • 存储器在被定义的时候会自动被命名。因此,不需要对模块进行明确的命名。
  • Pinia允许你建立多个store,让你的捆绑器代码自动分割它们
  • Pinia允许在其他getter中使用getter

近期个人练手项目

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");
  }
}