yang

yang 查看完整档案

南京编辑华中科技大学  |  计算机应用技术 编辑南京  |  前端工程师 编辑填写个人主网站
编辑

镜花水月何从影,云散缘由不是风。

个人动态

yang 收藏了文章 · 2020-12-23

ES6 中的Class中的关键字super

复习一下ES6 中的关于类的语法

定义一个类

class Animal {
    constructor(){
        this.type = 'animal';
    }
    speak(){
        console.log(this.type);
    }
}

相当于下面ES5的这样的写法

function Animal(){
   this.type = 'animal';
}

Animal.prototype.speak = function(){
   console.log(this.type);
}

类的继承

class Animal {
    constructor(){
        this.type = 'animal';
    }
    speak(){
        console.log(this.type);
    }
}

class Cat extends Animal {
    constructor(){
        super();
        this.type = 'cat'
    }
}

相当于下面ES5的写法

function Animal(){
   this.type = 'animal';
}

Animal.prototype.speak = function(){
   console.log(this.type);
}

function Cat(){
  Animal.call(this);
  this.type = 'animal';
}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat;//因为上一步造成constructor为Animal

//或者可以把上边的两行改成下面这样的写法
Cat.prototype = Object.create(Animal.prototype, {
  constructor: Cat,
});

super登场

从上边的例子可能已经领略了super的一些用法了。但是还不全面。super在类的继承中,有两个用法

  1. 作为函数使用,如上边的例子中的super()
  2. 作为对象使用, 如super.type

1. 把super作为函数使用

在类的继承中,子类如果显式的声明了构造函数,则必须首先调用super,不然会找不到this。此时super代表父类的构造函数。这时在构造函数里调用super(),相当于 parentConstructor.call(this). 还是以上边的实际例子为例。

class Cat extends Animal {
    constructor(){
        super();   // 相当于  Animal.call(this)
        this.type = 'cat'
    }
}

现在再解释一个疑问。如果在继承中子类压根不写构造函数呢?不过不写,相当于也写了。只是构造函数用的是父类的构造函数

class Cat extends Animal {
}

// 相当于
class Cat extends Animal {
    constructor(...args){
             super(...args);
    }
}

2.把super作为对象使用

super作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

在普通方法中,指向父类的原型对象,下面举例

class Animal {
    constructor(){
        this.type = 'animal';
    }
}

Animal.prototype.type ="type on propotype"

class Cat extends Animal {
    constructor(){
        super();
        this.type = 'cat'
               console.log(super.type)
    }
}
new Cat();  // 此处打印出来的是type on propotype,而不是animal

在静态方法中指向父类

class Animal {
  static type = 'this is static type';
    constructor(){
        this.type = 'animal';
    }
}

Animal.prototype.type ="type on propotype"

class Cat extends Animal {
    constructor(){
        super();
        this.type = 'cat'
    }
  static miao(){
    console.log(super.type);  // 这里输出的是this is static type,而不是animal或者type on propotype
  }
}

Cat.miao()
查看原文

yang 发布了文章 · 2020-12-21

React之Hook

什么是hook?

Hook 是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。Hook 不能在 class 组件中使用 —— 这使得你不使用 class 也能使用 React。

State Hook

import React, { useState } from 'react';
function Example() {
 // 声明一个叫 “count” 的 state 变量。 const [count, setCount] = useState(0);
  return (
    <div>
 <p>You clicked {count} times</p>
 <button onClick={() => setCount(count + 1)}>
 Click me
 </button>
 </div>
  );
}

在这里,useState 就是一个 Hook
通过在函数组件里调用它来给组件添加一些内部 state。
React 会在重复渲染时保留这个 state
useState 会返回一对值:当前状态和一个让你更新它的函数,
你可以在事件处理函数中或其他一些地方调用这个函数。它类似 class 组件的 this.setState

useState 唯一的参数就是初始 state。在上面的例子中,我们的计数器是从零开始的,所以初始 state 就是 0。值得注意的是,不同于 this.state,这里的 state 不一定要是一个对象 —— 如果你有需要,它也可以是。这个初始 state 参数只有在第一次渲染时会被用到。

声明多个 state 变量

你可以在一个组件中多次使用 State Hook:

function ExampleWithManyStates() {
  // 声明多个 state 变量!
  const [age, setAge] = useState(42);
  const [fruit, setFruit] = useState('banana');
  const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
  // ...
}

数组解构的语法让我们在调用 useState 时可以给 state 变量取不同的名字

image.png

第一行: 引入 React 中的 useState Hook。它让我们在函数组件中存储内部 state

  • 第四行:Example 组件内部,我们通过调用 useState Hook 声明了一个新的 state 变量。它返回一对值给到我们命名的变量上。我们把变量命名为 count,因为它存储的是点击次数。我们通过传 0 作为 useState 唯一的参数来将其初始化为 0。第二个返回的值本身就是一个函数。它让我们可以更新 count 的值,所以我们叫它 setCount
  • 第九行: 当用户点击按钮后,我们传递一个新的值给 setCount。React 会重新渲染 Example 组件,并把最新的 count 传给它。

Effect Hook

在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。

useEffect 就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMountcomponentDidUpdatecomponentWillUnmount 具有相同的用途,只不过被合并成了一个 API。

在 React 更新 DOM 后会设置一个页面标题:

import React,{useState,useEffect} from 'react'

function Example(){
    const [count,setCount] = useState(0)
    // 相当于componentDidMount和componentDidUpdate
    useEffect(()=>{
        // 使用浏览器的API更新页面标题
        document.title = `You clicked ${count}times`
    });
    return (
        <div>
            <p>
              You clicked {count} times
            </p>
            <button onClick={()=>setCount(count+1)}>
               Click me
            </button>
        </div>
    )
}

当你调用 useEffect 时,就是在告诉 React 在完成对 DOM 的更改后运行你的“副作用”函数。
由于副作用函数是在组件内声明的,所以它们可以访问到组件的 props 和 state。
默认情况下,React 会在每次渲染后调用副作用函数 —— 包括第一次渲染的时候。

副作用函数还可以通过返回一个函数来指定如何“清除”副作用。
在下面的组件中使用副作用函数来订阅好友的在线状态,并通过取消订阅来进行清除操作:

import React,{useState,useEffect} from 'react'
function FriendStatus(props){
    const [isOnline,setIsOnline] = useState(null)
    function handleStatusChange(status){
        setIsOnline(status.isOnline)
    }
    useEffect(()=>{
        ChatAPI.subscribeToFriendStatus(props.friend.id,handleStatusChange);
        return ()=>{
        ChatAPI.unsubscribeFromFriendStatus(props.friend.id,handleStatusChange)
        }
    })
    if(isOnline===null){
        return 'loadding...'
    }
    return isOnline? 'Online':'Offline'
}

React 会在组件销毁时取消对 ChatAPI 的订阅,然后在后续渲染时重新执行副作用函数。

useState 一样,你可以在组件中多次使用 useEffect

import React,{useState,useEffect} from 'react'
function FriendStatus(props){
    const [count,setCount] = useState(0)
    useEffect(()=>{
        document.title = `you clicked ${count} times`
    })
    const [isOnline,setIsOnline] = useState(null)
    function handleStatusChange(status){
        setIsOnline(status.isOnline)
    }
    useEffect(()=>{
        ChatAPI.subscribeToFriendStatus(props.friend.id,handleStatusChange);
        return ()=>{
        ChatAPI.unsubscribeFromFriendStatus(props.friend.id,handleStatusChange)
        }
    })
    if(isOnline===null){
        return 'loadding...'
    }
    return isOnline? 'Online':'Offline'
}

通过使用 Hook,你可以把组件内相关的副作用组织在一起(例如创建订阅及取消订阅),而不要把它们拆分到不同的生命周期函数里。

无需清除的 effect

有时候,我们只想在 React 更新 DOM 之后运行一些额外的代码。比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。因为我们在执行完这些操作之后,就可以忽略他们了。对比一下使用 class 和 Hook 都是怎么实现这些副作用的。

使用 class 的示例

在 React 的 class 组件中,render 函数是不应该有任何副作用的。一般来说,在这里执行操作太早了,我们基本上都希望在 React 更新 DOM 之后才执行我们的操作
这就是为什么在 React class 中,我们把副作用操作放到 componentDidMountcomponentDidUpdate 函数中
回到示例中,这是一个 React 计数器的 class 组件。它在 React 对 DOM 进行操作之后,立即更新了 document 的 title 属性

import React,{ Component} from "react";
class Example extends Component{
    constructor(props){
        super(props)
        this.state = {
            count:0
        }
    }
    componentDidMount(){
        document.title = `You clicked ${this.state.count} times`
    }
    componentDIdUpdate(){
        document.title = `You clicke ${this.state.count} times`
    }
    render(){
        return (
            <div>
                <p>you clicked{this.state.count} times</p>            
                <button onClick={()=>this.setState({count:this.state.count + 1})}>Click Me</button>
            </div>
        )
    }
}

在这个 class 中,我们需要在两个生命周期函数中编写重复的代码。
这是因为很多情况下,我们希望在组件加载和更新时执行同样的操作。从概念上说,我们希望它在每次渲染之后执行 —— 但 React 的 class 组件没有提供这样的方法。即使我们提取出一个方法,我们还是要在两个地方调用它。

使用 Hook 的示例

使用 useEffect 执行相同的操作。

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);

 useEffect(() => { document.title = `You clicked ${count} times`; });
  return (
    <div>
 <p>You clicked {count} times</p>
 <button onClick={() => setCount(count + 1)}>
 Click me
 </button>
 </div>
  );
}

我们声明了 count state 变量,并告诉 React 我们需要使用 effect。紧接着传递函数给 useEffect Hook。此函数就是我们的 effect。然后使用 document.title 浏览器 API 设置 document 的 title。我们可以在 effect 中获取到最新的 count 值,因为他在函数的作用域内。当 React 渲染组件时,会保存已使用的 effect,并在更新完 DOM 后执行它。这个过程在每次渲染时都会发生,包括首次渲染。

通过使用这个 Hook,你可以告诉 React 组件需要在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它。在这个 effect 中,我们设置了 document 的 title 属性,不过我们也可以执行数据获取或调用其他命令式的 API。

为什么在组件内部调用 useEffectuseEffect 放在组件内部让我们可以在 effect 中直接访问 count state 变量(或其他 props)。我们不需要特殊的 API 来读取它 —— 它已经保存在函数作用域中。Hook 使用了 JavaScript 的闭包机制,而不用在 JavaScript 已经提供了解决方案的情况下,还引入特定的 React API。

useEffect 会在每次渲染后都执行吗? 是的,默认情况下,它在第一次渲染之后_和_每次更新之后都会执行。React 保证了每次运行 effect 的同时,DOM 都已经更新完毕。

componentDidMountcomponentDidUpdate 不同的是,使用 useEffect 调度的 effect 不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快。大多数情况下,effect 不需要同步地执行。在个别情况下(例如测量布局),有单独的 useLayoutEffect Hook 供你使用,其 API 与 useEffect 相同。

需要清除的 effect

些副作用是需要清除的。例如订阅外部数据源。这种情况下,清除工作是非常重要的,可以防止引起内存泄露, 如何用 Class 和 Hook 来实现?

使用 Class 的示例

在 React class 中,你通常会在 componentDidMount 中设置订阅,并在 componentWillUnmount 中清除它。例如,假设我们有一个 ChatAPI 模块,它允许我们订阅好友的在线状态。以下是我们如何使用 class 订阅和显示该状态:

import React, { Component } from 'react'
class FriendStatus extends Component {
  constructor(prop) {
    super(props)
    this.state = { isOnline: null }
    this.handleStatusChange = this.handleStatusChange.bind(this)
  }
  componentDidMount(){
      ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentDidUnmount(){
      ChatAPI.unsubscribeFromFriendStatus(
          this.props.friend.id,
          this.handleStatusChange
      )
  }
  handleStatusChange(status){
       this.setState({
           isOnline:status.isOnline
       })
  }
  render(){
      if(this.state.isOnline === null){
          return 'loadding...'
      }
      return this.state.isOnline?'Online':'Offline'
  }
}

你会注意到 componentDidMountcomponentWillUnmount 之间相互对应。使用生命周期函数迫使我们拆分这些逻辑代码,即使这两部分代码都作用于相同的副作用。

使用 Hook 的示例

添加和删除订阅的代码的紧密性,所以 useEffect 的设计是在同一个地方执行。如果你的 effect 返回一个函数,React 将会在执行清除操作时调用它:

import React, { useState,useEffect } from 'react'

function FriendStatus(props){
    const [isOnline,setIsOnline] = useState(null)
    useEffect(()=>{
        function handleStatusChange(status){
            setIsOnline(status.isOnline)
        }
        ChatAPI.subscribeToFriendStatus(props.friend.id,handleStatusChange);
        return function cleanUp(){
            ChatAPI.unsubscribeFromFriendStatus(props.friend.id,handleStatusChange)
        }
    })
    if(isOnline === null){
        return 'loading...'
    }
    return isOnline?'Online':"Offline"
}

并不是必须为 effect 中返回的函数命名。这里我们将其命名为 cleanup 是为了表明此函数的目的,但其实也可以返回一个箭头函数或者给起一个别的名字。

为什么要在 effect 中返回一个函数? 这是 effect 可选的清除机制。每个 effect 都可以返回一个清除函数。如此可以将添加和移除订阅的逻辑放在一起。它们都属于 effect 的一部分。
React 何时清除 effect? React 会在组件卸载的时候执行清除操作。正如之前学到的,effect 在每次渲染的时候都会执行。这就是为什么 React _会_在执行当前 effect 之前对上一个 effect 进行清除。

其他的 effect 可能不必清除,所以不需要返回。

useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
多个 Effect 实现关注点分离

使用 Hook 其中一个目的就是要解决 class 中生命周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。
下述代码是将前述示例中的计数器和好友在线状态指示器逻辑组合在一起的组件:

class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0, isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }
  // ...

可以发现设置 document.title 的逻辑是如何被分割到 componentDidMountcomponentDidUpdate 中的,订阅逻辑又是如何被分割到 componentDidMountcomponentWillUnmount 中的。而且 componentDidMount 中同时包含了两个不同功能的代码。
那么 Hook 如何解决这个问题呢?就像你可以使用多个 state 的 Hook 一样,你也可以使用多个 effect。这会将不相关逻辑分离到不同的 effect 中:

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
 useEffect(() => {    document.title = `You clicked ${count} times`;
  });

  const [isOnline, setIsOnline] = useState(null);
 useEffect(() => {    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
  // ...
}

Hook 允许我们按照代码的用途分离他们, 而不是像生命周期函数那样。React 将按照 effect 声明的顺序依次调用组件中的_每一个_ effect。

解释: 为什么每次更新的时候都要运行 Effect

effect 的清除阶段在每次重新渲染时都会执行,而不是只在卸载组件的时候执行一次。
从 class 中 props 读取 friend.id,然后在组件挂载后订阅好友的状态,并在卸载组件的时候取消订阅:

componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

但是当组件已经显示在屏幕上时,friend prop 发生变化时会发生什么? 我们的组件将继续展示原来的好友状态。这是一个 bug。而且我们还会因为取消订阅时使用错误的好友 ID 导致内存泄露或崩溃的问题。
在 class 组件中,我们需要添加 componentDidUpdate 来解决这个问题:

componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

 componentDidUpdate(prevProps) { // 取消订阅之前的 friend.id ChatAPI.unsubscribeFromFriendStatus( prevProps.friend.id, this.handleStatusChange ); // 订阅新的 friend.id ChatAPI.subscribeToFriendStatus( this.props.friend.id, this.handleStatusChange ); }
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

使用 Hook 的版本:

function FriendStatus(props) {
  // ...
  useEffect(() => {
    // ...
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

它并不会受到此 bug 影响
并不需要特定的代码来处理更新逻辑,因为 useEffect _默认_就会处理。它会在调用一个新的 effect 之前对前一个 effect 进行清理。

通过跳过 Effect 进行性能优化

在某些情况下,每次渲染后都执行清理或者执行 effect 可能会导致性能问题
在 class 组件中,我们可以通过在 componentDidUpdate 中添加对 prevPropsprevState 的比较逻辑解决:

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

这是很常见的需求,所以它被内置到了 useEffect 的 Hook API 中。如果某些特定值在两次重渲染之间没有发生变化,你可以通知 React 跳过对 effect 的调用,只要传递数组作为 useEffect 的第二个可选参数即可:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

我们传入 [count] 作为第二个参数。这个参数是什么作用呢?如果 count 的值是 5,而且我们的组件重渲染的时候 count 还是等于 5,React 将对前一次渲染的 [5] 和后一次渲染的 [5] 进行比较。因为数组中的所有元素都是相等的(5 === 5),React 会跳过这个 effect,这就实现了性能的优化。
如果数组中有多个元素,即使只有一个元素发生变化,React 也会执行 effect。
对于有清除操作的 effect 同样适用:

useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 仅在 props.friend.id 发生变化时,重新订阅

如果你要使用此优化方式,请确保数组中包含了所有外部作用域中会随时间变化并且在 effect 中使用的变量,否则你的代码会引用到先前渲染中的旧变量。
如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组([])作为第二个参数。这就告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行。这并不属于特殊情况 —— 它依然遵循依赖数组的工作方式。
如果你传入了一个空数组([]),effect 内部的 props 和 state 就会一直拥有其初始值。
React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect,因此会使得额外操作很方便。

推荐启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议。

Hook 使用规则

Hook 就是 JavaScript 函数,但是使用它们会有两个额外的规则:

  • 只能在函数最外层调用 Hook。不要在循环、条件判断或者子函数中调用。
  • 只能在 React 的函数组件中调用 Hook。不要在其他 JavaScript 函数中调用。(还有一个地方可以调用 Hook —— 就是自定义的 Hook 中)

    linter 插件来自动执行这些规则

自定义 Hook

想要在组件之间重用一些状态逻辑。目前为止,有两种主流方案来解决这个问题:高阶组件render props。自定义 Hook 可以让你在不增加组件的情况下达到同样的目的。

FriendStatus 的组件,它通过调用 useStateuseEffect 的 Hook 来订阅一个好友的在线状态。假设我们想在另一个组件里重用这个订阅逻辑。

首先,我们把这个逻辑抽取到一个叫做 useFriendStatus 的自定义 Hook 里:

import React,{useState,useEffect} form 'react'
function useFriendStatus(friendID){
    const [isOnline,setIsOnline] = setState(null)
    function handleStatusChange(status){
        setIsOnline(status.isOnline)
    }
    useEffect(()=>{
        ChatAPI.subscribeToFriendStatus(friendID,handleStatusChange)
        return ()=>{
            ChatAPI.unsubscribeFromfriendStatus(friendID,handleStatusChange)
        }
    })
    return isOnline
}

它将 friendID 作为参数,并返回该好友是否在线:

现在我们可以在两个组件中使用它:

function FriendStatus(props){
    const isOnline = useFriendStatus(props.friend.id)
    if(isOnline === null){
        return 'loading....'
    }
    return isOnline?'Online':'Offline'
}
function FriendListItem(props){
    const isOnline = useFriendStatus(props.friend.id)
    return (
        <li style={{color:isOnline?'green':'black'}}>
            {props.friend.name}
        </li>
    )
}

每个组件间的 state 是完全独立的。Hook 是一种复用_状态逻辑_的方式,它不复用 state 本身。事实上 Hook 的每次_调用_都有一个完全独立的 state —— 因此你可以在单个组件中多次调用同一个自定义 Hook。

自定义 Hook 更像是一种约定而不是功能。如果函数的名字以 “use” 开头并调用其他 Hook,我们就说这是一个自定义 Hook。 useSomething 的命名约定可以让我们的 linter 插件在使用 Hook 的代码中找到 bug。

你可以创建涵盖各种场景的自定义 Hook,如表单处理、动画、订阅声明、计时器,甚至可能还有更多我们没想到的场景。

其他 Hook

除此之外,还有一些使用频率较低的但是很有用的 Hook。比如,useContext 让你不使用组件嵌套就可以订阅 React 的 Context。

function Example(){
    const local = useContext(LocaleContent)
    const theme = useContext(ThemeContext)
    // ...
}

另外 useReducer 可以让你通过 reducer 来管理组件本地的复杂 state。

function Todos(){
    const [todos,dispatch] = useReducer(todosReducer)
}
查看原文

赞 0 收藏 0 评论 0

yang 发布了文章 · 2020-12-17

Powershell 快捷键

Powershell的快捷键和cmd,linux中的shell,都比较像。

  • ALT+F7 清除命令的历史记录
  • PgUp PgDn 显示当前会话的第一个命令和最后一个命令
  • Enter 执行当前命令
  • End 将光标移至当前命令的末尾
  • Del 从右开始删除输入的命令字符
  • Esc 清空当前命令行
  • F2 自动补充历史命令至指定字符
  • (例如历史记录中存在Get-Process,按F2,提示"Enter char to copy up to",键入‘s’,自动补齐命令:Get-Proce)
  • F4 删除命令行至光标右边指定字符处
  • F7 对话框显示命令行历史记录
  • F8 检索包含指定字符的命令行历史记录
  • F9 根据命令行的历史记录编号选择命令,历史记录编号可以通过F7查看
  • 左/右方向键 左右移动光标
  • 上/下方向键 切换命令行的历史记录
  • Home 光标移至命令行最左端
  • Backspace 从右删除命令行字符
  • Ctrl+C 取消正在执行的命令
  • Ctrl+左/右方向键 在单词之间移动光标
  • Ctrl+Home 删除光标最左端的所有字符
  • Tab 自动补齐命令或者文件名
查看原文

赞 0 收藏 0 评论 0

yang 收藏了文章 · 2020-12-16

Lodash入门以及最常用方法汇总

一、Lodash介绍:

Lodash是一套工具库,它内部封装了诸多对字符串、数组、对象等常见数据类型的处理函数,帮助开发者降低JS使用难度。

二、Lodash入门使用介绍:

以Vue-cli使用为例:

1、yarn add lodash
2、在main.js里引入并使用

import _ from 'lodash';// 导入loadsh
Vue.prototype.$lodash = _;//注入工具

三、Lodash常用方法介绍:
①:times()循环

        //js原生的循环方法
        for (var i = 0; i < 5; i++) {
            console.log(i);
        };
        
        //ladash的times方法
        this.$lodash.times(5,function (item) {
            console.log(item);
        })

②:map() 获取对象数组中某一同名属性的属性值集合;

        let arr = [{owner: "brown",
            lovers: [{name: "cony"}, {name: "choco"}]
        }, {
            owner: "James",
            lovers: [{name: "sally"}, {name: "Jessica"}]
        }];

        //js原生的循环方法
        var jsMap = arr.map(function (item) {
            return item.lovers[0].name;
        });
        console.log(jsMap); //["cony", "sally"]


        // Lodash的写法
        var lodashMap = this.$lodash.map(arr, 'lovers[0].name');
        console.log(lodashMap); //["cony", "sally"]

③: get () 获取对象中的某个属性的值

let obj = {a: [{b: {c: 3}}]}
let c = this.$lodash.get(obj, 'a[0].b.c')  //c==3

④: cloneDeep() 深克隆对象

        let objA = {name: "brown"};

        //JS深克隆  
        JSON.parse(JSON.stringify(objA))

        // Lodash的方法
        let objB = this.$lodash.cloneDeep(objA);
        console.log(objA); //{name: "brown"}
        console.log(objB);//{name: "brown"}
        console.log(objA === objB); //false
        
        

⑤: find() 、filter()、 reject() 查找属性

let arr = [
    {owner: "brown", age:18},
    {owner: "James", age:20}
];

find()第一个返回真值的第一个元素。
filter()返回真值的所有元素的数组。
reject()是_.filter的反向方法,返回所有假值

        console.log(this.$lodash.find(lovers, function (item) {
            return item.age < 19;
        }));    // {owner: "brown", age: 18}


        console.log(this.$lodash.find(lovers, {age: 18, active: true}));
        // {lover: "sally", age: 18, active: true}


        console.log(this.$lodash.filter(lovers, {age: 18, active: true}));
        //[{lover: "sally", age: 18, active: true}]


        console.log(this.$lodash.find(lovers, ['active', false]));
        // {lover: "cony", age: 19, active: false}

        console.log(this.$lodash.filter(lovers, ['active', false]));
        // [{lover: "cony", age: 19, active: false}]
        
        console.log(this.$lodash.find(lovers, 'active'));
        // {lover: "sally", age: 18, active: true}

        console.log(this.$lodash.filter(lovers, 'active'));
        // [{lover: 'sally', age: 18, active: true},
        //  {lover: 'brown', age: 20, active: true},]
        
        

⑥: findIndex() 查找正确的第一个索引项

        var users = [
            { user: 'brown',  active: false },
            { user: 'cony',    active: false },
            { user: 'sally', active: true }
        ];
        this.$lodash.findIndex(users, function(chr) {
            return chr.user == 'sally';
        }); // 2

        this.$lodash.findIndex(users, { 'user': 'cony', 'active': false }); // 1
        this.$lodash.findIndex(users, 'active', false);// 0
        this.$lodash.findIndex(users, 'active'); // 2

⑦: assign()、merge() 合并
相同之处:都可以用来合并对象 都会修改原来的对象 (如果原来的对象是作为函数的第一个参数的话);

不同之处
assign 函数不会处理原型链上的属性,也不会合并相同的属性,而是用后面的属性值覆盖前面的属性值;
merge 遇到相同属性名的时候,如果属性值是纯对象或集合的时候,会合并属性值;

       // JS原生对象合并的方式
        Object.prototype.extend = function(obj) {
            for (var i in obj) {
                if (obj.hasOwnProperty(i)) {    //判断被扩展的对象有没有某个属性,
                    this[i] = obj[i];
                }
            }
        };
        var objA = {name: "brown", "food": "salmon"};
        var objB = {name: "cony", "loveEat": true};
        objA.extend(objB);
        console.log(objA); //{name: "cony", food: "salmon", loveEat: true}

        // Lodash的方式
        console.log(this.$lodash.assign(objA, objB));
        //{name: "cony", food: "salmon", loveEat: true}

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

        const aa = this.$lodash.assign({a:1},{a:2},{b:3}) //{a:2,b:3}
        const bb = this.$lodash.merge({a:1},{a:2},{b:3}) //{a:2,b:3}

        const a1 = this.$lodash.assign({},{a:1},{b:{a:1,b:2}},{b:{a:3}}) //{a:1,b:{a:3}}
        const a2 = this.$lodash.merge({},{a:1},{b:{a:1,b:2}},{b:{a:3}}) //{a:1,b:{a:3,b:2}}
        

⑧: forEach() 遍历循环

        
        this.$lodash(['a', 'b']).forEach(function(item) {
            console.log(item);// ab
        });

        this.$lodash.forEach(['a', 'b'] , function(item, key) {
            console.log(item,key); // a 0   b 1
        });

⑨:获取数组中指定元素 last() nth()

        //最后一个  last
        let arr = [1, 2, 3, 4, 5]
        let lastElement = this.$lodash.last(arr);
        console.log(lastElement); // 5
        
        //倒数第二个
        let lastSecondElement = this.$lodash.nth(arr,-2)
        console.log(lastSecondElement); // 4
        
        //第一个
        let lastSecondElement = this.$lodash.nth(arr,0)
        console.log(lastSecondElement); // 1
        
        

⑩:take()获取数组中前n个元素,不改变原数组

        //最后一个  last
        let arr = [1, 2, 3, 4, 5]
        let lastElement = this.$lodash.last(arr);
        console.log(lastElement); // 5

        //倒数第二个
        let lastSecondElement = this.$lodash.nth(arr,-2)
        console.log(lastSecondElement); // 4

        //第一个
        let lastSecondElement = this.$lodash.nth(arr,0)
        console.log(lastSecondElement); // 1

十一: values() 把 object 自身可枚举属性的值为数组

        
        var obj = {
            a: {
                "leaf": 1
            },
            b:{
                "leaf": 2
            }
        }

        console.log(this.$lodash.values(obj)); // [{leaf: 1},{leaf: 2}]
        

十二: pick() 从 object 中提取指定的对象

 var object = { 'a': 1, 'b': '2', 'c': 3 };
 this.$lodash.pick(object, ['a', 'c']);
 
 // => { 'a': 1, 'c': 3 }
 
 
 let arr = ['customerId', 'customerNo', 'warehouseId', 'warehouseNo', 'warehouseName', 'gsoId', 'gsoNo', 'gsoName']  
 let pick = this.$lodash.pick(item, arr) //发请求
查看原文

yang 发布了文章 · 2020-12-15

npm 报错

1.“This is probably not a problem with npm. There is likely additional logging output above. ”
解决方法:

rm -rf node_modules
rm package-lock.json
npm cache clear --force
npm install

2 “npm ERR! A complete log of this run can be found in: npm ERR! D:\node\node_cache\_logs\2020-06-13T08_12_35_648Z-debug.log”

解决方法

npm install npm -g 

npm install 安装一下依赖即可

npm 报错一般是

  • 缺少依赖 【视情况 仔细看报错信息】
  • 文件引用错误 【视情况 仔细看报错信息】
  • node_moudule依赖问题 【删除重新下载】
  • webpack版本问题 【版本过高,降低版本】
  • node的版本问题 【版本过高,降低版本】
查看原文

赞 0 收藏 0 评论 0

yang 收藏了文章 · 2020-12-14

小程序canvas写一个简单的图片应用

很早就想写一个移动端图片小应用了,最近刚好休业在家,于是抽出时间实现下自己的小想法

应用展示

截图

image

在线预览

image

需求

既然是小应用,那就希望最终成品是有 适用的场景 且是 有价值

需求来源

这个应用需求的灵感

在以前工作生活中,经常会无意中获得同事的 美照

这时我们想要把这张照片做成表情包

一般给图片添加几个说明文字

一个有意思的沟通工具(表情包)就完成了

需求分析

基于以上需求的拆解

可以将要应用功能实现整理一下

  • 用户需要上传一张图片
  • 可以添加文字
  • 文字可以作 样式调整旋转缩放
  • 另外我们希望还可以插入一些贴图
  • 贴图可以做 旋转缩放
  • 用户导出图片到相册

实现

github仓库https://github.com/luosijie/f...

如果喜欢我的项目,欢迎给个星星鼓励一下

这个应用是用小程序开发的

  • 使用框架:mpx
  • 使用技术:小程序canvas

状态管理

import { createStore } from '@mpxjs/core'

const store = createStore({
  state: {
    cavas: null,         // cnavas实例
    ctx: null,           // canvas上下文实例
    elements: [],        // canvas元素
    activeIndex: null,   // 当前编辑中的元素索引
    mode: 'background',  // 当前编辑模式:background, text, sticker
    fontStyle: {         // 文字默认样式
      opacity: 1,
      fillStyle: '#000000',
      strokeStyle: '#000000'
    }
  },
  mutations: {
    setCanvas (state, data) {
      state.canvas = data
    },
    setCtx (state, data) {
      state.ctx = data
    },
    setElements (state, data) {
      state.elements = data
    },
    setMode (state, data) {
      state.mode = data
    },
    setActiveIndex (state, data) {
      state.activeIndex = data
    },
    setFontStyle (state, { key, data }) {
      state.fontStyle[key] = data
    },
    // 添加文字
    addText (state) {
      const size = 50
      const string = '请输入文字'
      const text = {
        type: 'text',
        data: string,
        scale: 1,
        size,
        left: 100,
        top: 100,
        rotate: 0,
        opacity: state.fontStyle.opacity,
        fillStyle: state.fontStyle.fillStyle,
        strokeStyle: state.fontStyle.strokeStyle
      }
      state.elements.push(text)
      state.activeIndex = state.elements.length - 1
    },
    // 添加贴图
    addSticker (state, data) {
      state.elements.push(data)
      state.activeIndex = state.elements.length - 1
    },
    // 删除当前选中
    deleteActiveELement (state) {
      state.elements.splice(state.activeIndex, 1)
      state.activeIndex = null
    },
    // 清空画布
    clear (state) {
      state.elements = []
      state.activeIndex = null
    }
  }
})

export default store

画布初始化

// 初始化画布
async initCanvas() {
  const query = this.createSelectorQuery()
  query
    .select('#canvas')
    .fields({ node: true, size: true })
    .exec(async res => {
      const canvas = res[0].node
      const ctx = canvas.getContext('2d')
      store.commit('setCanvas', canvas)
      store.commit('setCtx', ctx)

      await this.loadImage('/images/icon-rotate.png').then(res => {
        this.image.rotate = res
      })

      canvas.width = res[0].width * this.dpr
      canvas.height = res[0].height * this.dpr
      ctx.scale(this.dpr, this.dpr)
      this.drawGrid()
    })
}

绘制图片

/**
 * 绘制图片
 * @param { Object } ele canvas元素
 */
drawImage(ele) {
  this.ctx.save()
  const width = ele.width
  const height = ele.height
  const centerX = ele.left + ele.width / 2
  const centerY = ele.top + ele.height / 2
  this.ctx.translate(centerX, centerY)
  this.ctx.rotate(ele.rotate)
  this.ctx.drawImage(ele.data, ele.left - centerX, ele.top - centerY, width, height)
  this.ctx.restore()
}

绘制文字

/**
 * 绘制文字
 * @param { Object } ele canvas元素
 */
drawText(ele) {
  this.ctx.save()
  const width = ele.size * ele.data.length
  const height = ele.size
  const centerX = ele.left + width / 2
  const centerY = ele.top + height / 2
  this.ctx.translate(centerX, centerY)
  this.ctx.rotate(ele.rotate)
  this.ctx.font = `${ele.size}px bold sans-serif`
  this.ctx.globalAlpha = ele.opacity
  this.ctx.fillStyle = ele.fillStyle
  this.ctx.strokeStyle = ele.strokeStyle
  // this.ctx.lineWidth = 2
  this.ctx.textBaseline = 'top'
  console.log('draw-text', ele)
  this.ctx.fillText(ele.data, ele.left - centerX, ele.top - centerY)
  this.ctx.strokeText(ele.data, ele.left - centerX, ele.top - centerY)
  this.ctx.restore()
}

绘制控制元件

initController(ele) {
  const cs = this.convert2ControllerSize(ele)
  this.ctx.save()
  this.ctx.strokeStyle = '#eee'
  this.ctx.translate(cs.centerX, cs.centerY)
  this.ctx.rotate(cs.rotate)
  // 绘制虚线边框
  this.ctx.setLineDash([10, 5], 5)
  this.ctx.strokeRect(cs.left - cs.centerX, cs.top - cs.centerY, cs.width, cs.height)
  // 绘制控制点-旋转
  this.ctx.drawImage(this.image.rotate, cs.left + cs.width - 10 - cs.centerX, cs.top + cs.height - 10 - cs.centerY, 20, 20)
  this.ctx.restore()
}

画布渲染函数

// 画布渲染函数
renderCanvas() {
  this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
  this.drawGrid()
  console.log('draw-background', this.background)
  if (this.background) this.drawImage(this.background)
  for (let i = 0; i < this.elements.length; i++) {
    const ele = this.elements[i]
    // 渲染背景
    if (ele.type === 'background') {
      this.drawImage(ele)
    }
    if (ele.type === 'sticker') {
      this.drawImage(ele)
    }
    // 渲染文字
    if (ele.type === 'text') {
      this.drawText(ele)
    }
    // 选中元素添加控制元件
    if (this.activeIndex === i) {
      this.initController(ele)
    }
  }
}

事件监听

移动

// 移动事件绑定函数
handleMove(e) {
  console.log('mouse-move', e)
  if (e.touches.length > 1) return
  const x = e.touches[0].x
  const y = e.touches[0].y
  const dx = this.startTouches[0].x - x
  const dy = this.startTouches[0].y - y
  const elements = this.elements.slice()
  elements[this.activeIndex || 0].left = this.startSelected.left - dx
  elements[this.activeIndex || 0].top = this.startSelected.top - dy
  store.commit('setElements', elements)
}

旋转

// 旋转绑定函数
handleRotate(e) {
  console.log('handleRotate')
  const start = this.startTouches[0]
  const end = e.touches[0]
  const center = {
    x: this.startSelected.centerX,
    y: this.startSelected.centerY
  }
  const startLength = Math.sqrt((center.x - start.x) ** 2 + (center.y - start.y) ** 2)
  const endLength = Math.sqrt((center.x - end.x) ** 2 + (center.y - end.y) ** 2)
  const radian = this.convert2Radian(start, end, center)
  const scale = endLength / startLength
  const elements = this.elements.slice()
  const selected = elements[this.activeIndex]
  // 旋转
  selected.rotate = this.startSelected.rotate - radian
  // 缩放
  if (selected.type === 'text') {
    selected.left = this.startSelected.centerX - this.startSelected.size * this.startSelected.data.length * scale / 2
    selected.top = this.startSelected.centerY - this.startSelected.size * scale / 2
    selected.size = this.startSelected.size * scale
  }
  if (selected.type === 'sticker') {
    selected.left = this.startSelected.centerX - this.startSelected.width * scale / 2
    selected.top = this.startSelected.centerY - this.startSelected.height * scale / 2
    selected.width = this.startSelected.width * scale
    selected.height = this.startSelected.height * scale
  }
  store.commit('setElements', elements)
}

缩放

// 缩放事件绑定函数
handleScale(e) {
  if (e.touches.length !== 2 || this.mode !== 'background') return
  const startLength = Math.sqrt(
    (this.startTouches[0].x - this.startTouches[1].x) ** 2 +
      (this.startTouches[0].y - this.startTouches[1].y) ** 2
  )
  const endLength = Math.sqrt(
    (e.touches[0].x - e.touches[1].x) ** 2 + (e.touches[0].y - e.touches[1].y) ** 2
  )
  const scale = endLength / startLength
  const elements = this.elements.slice()
  const selected = elements[this.activeIndex || 0]
  selected.left = this.startSelected.centerX - this.startSelected.width * scale / 2
  selected.top = this.startSelected.centerY - this.startSelected.height * scale / 2
  selected.width = this.startSelected.width * scale
  selected.height = this.startSelected.height * scale
  // elements[this.activeIndex || 0].scale = this.startSelected.scale * scale
  store.commit('setElements', elements)
}

导出图片

export() {
  if (!store.state.elements.length) {
    wx.showToast({
      title: '加点东西再导出吧',
      icon: 'none'
    })
    return
  }
  wx.showModal({
    title: '提示',
    content: '图片将保存到手机相册',
    success(res) {
      if (res.confirm) {
        console.log('export-canvas', store.state.ctx)
        const canvas = store.state.canvas
        wx.canvasToTempFilePath({
          x: 0,
          y: 0,
          width: canvas.width,
          height: canvas.height,
          canvas,
          complete(res) {
            if (res.errMsg === 'canvasToTempFilePath:ok') {
              wx.saveImageToPhotosAlbum({
                filePath: res.tempFilePath,
                success(res) {
                  wx.showToast({
                    title: '图片保存成功',
                    icon: 'none'
                  })
                }
              })
            }
          }
        })
      }
    }
  })
}
项目持续更新优化,感兴趣的朋友可以关注一下,一起交流学习

github仓库https://github.com/luosijie/f...

查看原文

yang 收藏了文章 · 2020-12-07

宇宙最强vscode教程(基础篇)

本文主要介绍vscode在工作中常用的快捷键及插件,目标在于提高工作效率

本文的快捷键是基于mac的,windows下的快捷键放在括号里 Cmd+Shift+P(win Ctrl+Shift+P)

[TOC]

零、快速入门

有经验的可以跳过快速入门或者大致浏览一遍

1. 命令面板

命令面板是vscode快捷键的主要交互界面,可以使用f1或者Cmd+Shift+P(win Ctrl+Shift+P)打开。

在命令面板中你可以输入命令进行搜索(中英文都可以),然后执行。

命名面板中可以执行各种命令,包括编辑器自带的功能和插件提供的功能。

所以一定要记住它的快捷键Cmd+Shift+P

image-20190120143658078

2. 界面介绍

刚上手使用vscode时,建议要先把它当做一个文件编辑器(可以打字然后保存),等到有了一定经验再去熟悉那些快捷键

先来熟悉一下界面及快捷命令(不用记)

3. 在命令行中使用vscode

如果你是 Windows用户,安装并重启系统后,你就可以在命令行中使用 code 或者 code-insiders了,如果你希望立刻而不是等待重启后使用,可以将 VS Code 的安装目录添加到系统环境变量 PATH

如果你是mac用户,安装后打开命名面板Cmd+Shift+P,搜索shell命令,点击在PAth中安装code命令,然后重启终端就ok了

image-20190120144757840

最基础的使用就是使用code命令打开文件或文件夹

code 文件夹地址,vscode 就会在新窗口中打开该文件夹

如果你希望在已经打开的窗口打开文件,可以使用-r参数

vscode命令还有其他功能,比如文件比较,打开文件跳转到指定的行和列,如有需要自行百度:bowing_woman:

注意:

在继续看文章之前记住记住打开命令面板的快捷键Cmd+shift+P(win下是Ctrl+shift+p)

一、代码编辑

windows下的快捷键放在括号里

光标的移动

基础

  1. 移动到行首 Cmd+左方向键 (win Home)
  2. 移动到行尾 Cmd+右方向键 (win End)
  3. 移动到文档的开头和末尾 Cmd+上下方向键 (win Ctrl+Home/End)
  4. 在花括号{}左边右边之间跳转 Cmd+Shift+ (win Ctrl+Shift+)

进阶

  1. 回到上一个光标的位置,Cmd+U(win Ctrl+U) 非常有用,有时候vue文件,你改了html,需要去下面改js,改完js又需要回去,这时候Cmd+U直接回
  2. 在不同的文件之间回到上一个光标的位置 Control+- (win 没测试,不知道),你改了a文件,改了b文件之后想回到a文件继续编辑,mac使用controls+-

文本选择

  1. 你只需要多按一个shift键就可以在光标移动的时候选中文本
  2. 选中单词 Cmd+D 下面要讲的多光标也会讲到Cmd+D
  3. 对于代码块的选择没有快捷键,可以使用cmd+shift+p打开命令面板,输入选择括号所有内容,待会说下如何添加快捷键

1

删除

  1. 你可以选中了代码之后再删除,再按Backpack(是backpack吗)或者delete删除,但是那样做太low了
  2. 所以,最Geek的删除方式是Cmd+Shift+K (win Ctrl+Shift+K),想删多少删多少,当前你可以使用ctrl+x剪切,效果一样的

2

代码移动

  • Option+上下方向键(win Alt+上下)

3

  • 代码移动的同时按住shift就可以实现代码复制 Option+Shift+上下3

添加注释

注释有两种形式,单行注释和块注释(在js中,单行注释//,块注释/**/)

  • 单行注释 Cmd+/ (win Ctrl +/)
  • 块注释 Option+Shift+A

注意:不同语言使用的注释不同

二、代码格式

代码格式化

  • 对整个文档进行格式化:Option+Shift+F (win Alt+Shift+F),vscode会根据你使用的语言,使用不同的插件进行格式化,记得要下载相应格式化的插件
  • 对选中代码进行格式化: Cmd+K Cmk+F win(Ctrl+K Ctrl+F)

代码缩进

  • 真个文档进行缩进调节,使用Cmd+Shift+P打开命令面板,输入缩进,然后选择相应的命令
  • 选中代码缩进调节:Cmd+] Cmd+[ 分别是减小和增加缩进(win 下不知道,自行百度)

三、一些小技巧

  • 调整字符的大小写,选中,然后在命令面板输入转化为大写或者转化为小写

  • 合并代码行,多行代码合并为一行,Cmd+J(win下未绑定)

  • 行排序,将代码行按照字母顺序进行排序,无快捷键,调出命令面板,输入按升序排序或者按降序排序

四、多光标特性

使用鼠标:

按住Option(win Alt),然后用鼠标点,鼠标点在哪里哪里就会出现一个光标

注意:有的mac电脑上是按住Cmd,然后用鼠标点才可以

快捷命令

  1. Cmd+D (win Ctrl+D) 第一次按下时,它会选中光标附近的单词;第二次按下时,它会找到这个单词第二次出现的位置,创建一个新的光标,并且选中它。(注:cmd-k cmd-d 跳过当前的选择)

  2. Option+Shift+i (win Alt+Shift+i) 首先你要选中多行代码,然后按Option+Shift+i,这样做的结果是:每一行后面都会多出来一个光标

撤销多光标

  • 使用Esc 撤销多光标
  • 鼠标点一下撤销

五、快速跳转(文件、行、符号)

快速打开文件

Cmd+P (win Ctrl+P)输入你要打开的文件名,回车打开

这里有个小技巧,选中你要打开的文件后,按Cmd+Enter,就会在一个新的编辑器窗口打开(窗口管理,见下文)

在tab不同的文件间切换,cmd+shift+[]

行跳转

加入浏览器报了个错,错误在53行,如何快速跳转到53行

Ctrl+g 输入行号

如果你想跳转到某个文件的某一行,你只需要先按下 “Cmd + P”,输入文件名,然后在这之后加上 “:”和指定行号即可。

符号跳转

符号可以是文件名、函数名,可以是css的类名

Cmd+Shift+O(win Ctrl+Shift+o) 输入你要跳转的符号,回车进行跳转

win下输入Ctrl+T,可以在不同文件的符号间进行搜索跳转

定义(definition)和实现(implementation)处

f12跳到函数的定义处

Cmd+f12(win Ctrl+f12)跳转到函数的实现处

引用跳转

很多时候,除了要知道一个函数或者类的定义和实现以外,你可能还希望知道它们被谁引用了,以及在哪里被引用了。这时你只需要将光标移动到函数或者类上面,然后按下 Shift + F12,VS Code 就会打开一个引用列表和一个内嵌的编辑器。在这个引用列表里,你选中某个引用,VS Code 就会把这个引用附近的代码展示在这个内嵌的编辑器里。

六、代码重构

当我们想修改一个函数或者变量的名字时候,我们只需把光标放到函数或者变量名上,然后按下 F2,这样这个函数或者变量出现的地方就都会被修改。

查看原文

yang 收藏了文章 · 2020-11-25

10 个 GitHub 上超火的 CSS 奇技淫巧项目,找到写 CSS 的灵感!

大家好,我是你们的 超级猫,一个不喜欢吃鱼、又不喜欢喵 的超级猫 ~

如果 CSS 是女孩子,肯定如上图那样吧 🤩 ~

简介

一般人没事的时候刷刷朋友圈、微博、电视剧、知乎,而有些人是没事的时候刷刷 GitHub ,看看最近有哪些流行的项目。

久而久之,这差距就越来越大,因此总会有开源信息的不对称,有哪些优秀的前端开源项目值得学习的也不知道。

初步前端与高级前端之间,最大的差距可能就是信息差造成的。

超级猫从 2016 年加入 GitHub,到现在的 2020 年,快整整 5 个年头了。

从 2018 年开始,我就养成了每天逛 GitHub 的习惯,一般在早上上班前或者中午午休的时候都会逛一下。

看看每天都开源了哪些好的前端项目,还有用到的主流前端技术栈又是哪些,值得我去学习的。

因此也收藏了不少好的开源项目,在此推荐给大家,每周会有一到三篇精华文章推送。

希望你在浏览、学习了超级猫推荐的这些开源项目的过程中,你能学习到更多编程知识、提高编程技巧、找到编程的乐趣。

公众号:前端GitHub,专注于挖掘 GitHub 上优秀的前端开源项目,抹平你的前端信息不对称,涵盖 JavaScript、Vue、React、Node、小程序、Flutter、Deno、HTML、CSS、数据结构与算法 等等。

平时如何发现好的开源项目,可以看看这篇文章:GitHub 上能挖矿的神仙技巧 - 如何发现优秀开源项目


以下为【前端GitHub】的第 7 期精华内容。

今天给大家带来的是 GitHub 上超火的 10 个 CSS 项目,希望你在这里面找到写 CSS 的灵感!

喵~ 喵~ 喵~ 正文开始了,上车坐稳扶好了~


You-need-to-know-css

该项目是 CSS 的各种效果实现,尤其是动画效果。

笔者把自己的收获和工作中常用的一些 CSS 小样式总结成这份文档。

目前文档一共包含 43 个 CSS 的小样式(持续更新…),所以还是很不错的学习 CSS 的项目来的。

比如: 打字效果

<style>
  main {
    width: 100%; height: 229px;
    display: flex;
    justify-content: center;
    align-items: center;
  }
  span {
    display: inline-block;
    width: 21ch;
    font: bold 200% Consolas, Monaco, monospace;   /*等宽字体*/
    overflow: hidden;
    white-space: nowrap;
    font-weight: 500;
    border-right: 1px solid transparent;
    animation: typing 10s steps(21), caret .5s steps(1) infinite;
  }
  @keyframes typing{
    from {
        width: 0;
    }
  }
  @keyframes caret{
    50% { border-right-color: currentColor}
  }
</style>
<template>
  <main class="main">
    <span>前端GitHub</span>
  </main>
</template>
<script>
</script>
https://lhammer.cn/You-need-to-know-css/#/zh-cn/

CSS-Inspiration

这里可以让你寻找到使用或者是学习 CSS 的灵感,以分类的形式,展示不同 CSS 属性或者不同的课题使用 CSS 来解决的各种方法。

包含了:布局(Layout)、阴影(box-shadow、drop-shadow)、伪类/伪元素、滤镜(fliter)、边框(border)、背景/渐变(linear-gradient/radial-gradient/conic-gradient)、混合模式(mix-blend-mode/background-blend-mode)、3D、动画/过渡(transition/animation)、clip-path、文本类、综合、CSS-Doodle、SVG 等内容。

比如:巧用 CSS 实现酷炫的充电动画

https://github.com/chokcoco/CSS-Inspiration

css_tricks

该项目总结了一些常用的 CSS 样式,记录一些 CSS 的新属性和一点奇技淫巧。

比如 提示气泡的效果

<div class="poptip btn" aria-controls="弹出气泡">poptip</div>
$poptipBg: #30363d;
$color: #fff;
$triangle: 8px;
$distance: -12px;
.poptip {
  position: relative;
  z-index: 101;
  &::before,
  &::after {
    visibility: hidden;
    opacity: 0;
    transform: translate3d(0, 0, 0);
    transition: all 0.3s ease 0.2s;
    box-sizing: border-box;
  }
  &::before {
    content: "";
    position: absolute;
    width: 0;
    height: 0;
    border-style: solid;
    border-width: $triangle $triangle 0 $triangle;
    border-color: $poptipBg transparent transparent transparent;
    left: calc(50% - #{$triangle});
    top: 0px;
    transform: translateX(0%) translateY($distance);
  }

  &::after {
    font-size: 14px;
    color: $color;
    content: attr(aria-controls);
    position: absolute;
    padding: 6px 12px;
    white-space: nowrap;
    z-index: -1;
    left: 50%;
    bottom: 100%;
    transform: translateX(-50%) translateY($distance);
    background: $poptipBg;
    line-height: 1;
    border-radius: 2px;
  }
  &:hover::before,
  &:hover::after {
    visibility: visible;
    opacity: 1;
  }
}

.btn {
  min-width: 100px;
  line-height: 1.5;
  padding: 5px 10px;
  color: #fff;
  background: #00adb5;
  border-radius: 4px;
  text-align: center;
  cursor: pointer;
}

效果:

https://github.com/QiShaoXuan/css_tricks

animista

该项目里面有各种 CSS 实现的效果,还有代码演示,方便直接复制代码,还可以复制压缩后的代码,如果你在找某个 CSS 的效果的话,可以到这里找找看。

http://animista.net/

spinkit

汇集了实现各种加载效果的 CSS 代码片段。

SpinKit 仅使用(transformopacity)CSS 动画来创建平滑且易于自定义的动画。

https://tobiasahlin.com/spinkit/

十天精通 CSS3

这是前端大佬大漠出的一个免费的 CSS3 教程,对于有一定 CSS2 经验的伙伴,能让您系统的学习 CSS3,快速的理解掌握并应用于工作之中。

里面的内容有讲解,还有代码演习,学完之后,可以练习所学的 api ,真的很不错。

超级猫入门前端时,也学习过里面的内容呢,虽然现在忘记的差不多了 😂,但是学过!。

https://www.imooc.com/learn/33

Animate

是一个有趣的,跨浏览器的 css3 动画库,内置了很多典型的 css3 动画,兼容性好使用方便。

animate.css 的使用非常简单,因为它是把不同的动画绑定到了不同的类里,所以想要使用哪种动画,只需要把通用类 animated 和相应的类添加到元素上就行了。

做为一个前端开发,如果不知道这个库就真的很失败了。

https://animate.style/

sass

Sass 是一种 CSS 的预编译语言,Sass 为 CSS 赋予了更强大的功能。

它提供了 变量(variables)、嵌套(nested rules) [混合(mixins)、函数(functions)等功能,并且完全兼容 CSS 语法。

Sass 能够帮助复杂的样式表更有条理, 并且易于在项目内部或跨项目共享设计。

https://sass.bootcss.com/documentation

less

Less 是一门 CSS 预处理语言,它扩展了 CSS 语言,增加了变量、Mixin、函数等特性。

Less 可以运行在 Node 或浏览器端。

https://less.bootcss.com/

stylus

富有表现力、动态、健壮的 CSS。

它提供了一种高效,动态和表达方式来生成 CSS。同时支持缩进语法和常规 CSS 样式。

https://stylus-lang.com/

CSS 预处理器技术已经非常的成熟了,而且也涌现出了越来越多的 CSS 的预处理器框架。

对于 sass 、less 和 stylus,都是在现在的 vue 和 react 项目中经常用到的,用法也很简单,只要学会一种,其他两种都很容易上手,项目中用哪一种就要看自己的喜欢了。

最后

最近加班有点严重,所有文章更新慢了很多,同是打工猫,生活不易啊!

好了啦,【前端GitHub】的第 7 期内容已经讲完了啦。

更多精彩内容请关注下方仓库:

原文地址:https://github.com/FrontEndGitHub/FrontEndGitHub

平时如何发现好的开源项目,可以看看这两篇文章:GitHub 上能挖矿的神仙技巧 - 如何发现优秀开源项目恕我直言,你可能连 GitHub 搜索都不会用 - 如何精准搜索的神仙技巧

可以加超级猫的 wx:CB834301747 ,一起闲聊前端。

觉得有用 ?喜欢就收藏,顺便点个赞吧,你的支持是我最大的鼓励!

往期精文

查看原文

yang 发布了文章 · 2020-11-17

性能优化之笔记

性能优化分为网络优化和渲染优化

从输入 URL 到页面加载完成,发生了什么?
首先我们需要通过 DNS(域名解析系统)将 URL 解析为对应的 IP 地址,然后与这个 IP 地址确定的那台服务器建立起 TCP 网络连接,随后我们向服务端抛出我们的 HTTP 请求,服务端处理完我们的请求之后,把目标数据放在 HTTP 响应里返回给客户端,拿到响应数据的浏览器就可以开始走一个渲染的流程。渲染完毕,页面便呈现给了用户,并时刻等待响应用户的操作
各个优化
DNS 解析花时间,能不能尽量减少解析次数或者把解析前置?能——浏览器 DNS 缓存和 DNS prefetch
TCP 每次的三次握手都急死人,有没有解决方案?有——长连接、预连接、接入 SPDY 协议
这两个过程的优化往往需要我们和团队的服务端工程师协作完成,
HTTP 请求 减少请求次数和减小请求体积方面

浏览器端的性能优化——这部分涉及资源加载优化、服务端渲染、浏览器缓存机制的利用、DOM 树的构建、网页排版和渲染过程、回流与重绘的考量、DOM 操作的合理规避等等

先说网络优化

从输入 URL 到显示页面这个过程中,涉及到网络层面的,有三个主要过程:

  • DNS 解析
  • TCP 连接
  • HTTP 请求/响应

对于 DNS 解析和 TCP 连接两个步骤,我们前端可以做的努力非常有限。相比之下,HTTP 连接这一层面的优化才是我们网络优化的核心
HTTP 优化有两个大的方向:

  • 减少请求次数
  • 减少单次请求所花费的时间

指向了我们日常开发中非常常见的操作——资源的压缩与合并
这就是我们用构建工具在做的事情

webpack 的性能瓶颈

webpack 的优化瓶颈,主要是两个方面:

  • webpack 的构建过程太花时间
  • webpack 打包的结果体积太大

webpack 优化方案

构建过程提速策略

不要让 loader 做太多事情——以 babbabel-loader 无疑是强大的,但它也是慢的。

babel-loader 无疑是强大的,但它也是慢的。

最常见的优化方式是,用 include 或 exclude 来帮我们避免不必要的转译,比如 webpack 官方在介绍 babel-loader 时给出的示例

module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /(node_modules|bower_components)/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      }
    }
  ]
}

这段代码帮我们规避了对庞大的 node_modules 文件夹或者 bower_components 文件夹的处理。但通过限定文件范围带来的性能提升是有限的。除此之外,如果我们选择开启缓存将转译结果缓存至文件系统,则至少可以将 babel-loader 的工作效率提升两倍。要做到这点,我们只需要为 loader 增加相应的参数设定:

loader: 'babel-loader?cacheDirectory=true'

这个规则仅作用于这个 loader,像一些类似 UglifyJsPlugin 的 webpack 插件在工作时依然会被这些庞大的第三方库拖累,webpack 构建速度依然会因此大打折扣。

第三方库的处理

处理第三方库的姿势有很多,其中,Externals 不够聪明,一些情况下会引发重复打包的问题;而 CommonsChunkPlugin 每次构建时都会重新构建一次 vendor;出于对效率的考虑,我们这里为大家推荐 DllPlugin。
DllPlugin 是基于 Windows 动态链接库(dll)的思想被创作出来的。这个插件会把第三方库单独打包到一个文件中,这个文件就是一个单纯的依赖库。这个依赖库不会跟着你的业务代码一起被重新打包,只有当依赖自身发生版本变化时才会重新打包

用 DllPlugin 处理文件,要分两步走:

  • 基于 dll 专属的配置文件,打包 dll 库
  • 基于 webpack.config.js 文件,打包业务代码
    以一个基于 React 的简单项目为例,我们的 dll 的配置文件可以编写如下:
const path = require('path')
const webpack = require('webpack')

module.exports = {
    entry: {
      // 依赖的库数组
      vendor: [
        'prop-types',
        'babel-polyfill',
        'react',
        'react-dom',
        'react-router-dom',
      ]
    },
    output: {
      path: path.join(__dirname, 'dist'),
      filename: '[name].js',
      library: '[name]_[hash]',
    },
    plugins: [
      new webpack.DllPlugin({
        // DllPlugin的name属性需要和libary保持一致
        name: '[name]_[hash]',
        path: path.join(__dirname, 'dist', '[name]-manifest.json'),
        // context需要和webpack.config.js保持一致
        context: __dirname,
      }),
    ],
}

编写完成之后,运行这个配置文件,我们的 dist 文件夹里会出现这样两个文件:

vendor-manifest.json
vendor.js

vendor.js 不必解释,是我们第三方库打包的结果。这个多出来的 vendor-manifest.json,则用于描述每个第三方库对应的具体路径,我这里截取一部分给大家看下:


{
  "name": "vendor_397f9e25e49947b8675d",
  "content": {
    "./node_modules/core-js/modules/_export.js": {
      "id": 0,
        "buildMeta": {
        "providedExports": true
      }
    },
    "./node_modules/prop-types/index.js": {
      "id": 1,
        "buildMeta": {
        "providedExports": true
      }
    },
    ...
  }
}  

随后,我们只需在 webpack.config.js 里针对 dll 稍作配置:

const path = require('path');
const webpack = require('webpack')
module.exports = {
  mode: 'production',
  // 编译入口
  entry: {
    main: './src/index.js'
  },
  // 目标文件
  output: {
    path: path.join(__dirname, 'dist/'),
    filename: '[name].js'
  },
  // dll相关配置
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      // manifest就是我们第一步中打包出来的json文件
      manifest: require('./dist/vendor-manifest.json'),
    })
  ]
}

以上也可用有些繁琐也可用AutoDllPlugin替代

 npm install --save-dev autodll-webpack-plugin

使用


const AutoDllPlugin = require('autodll-webpack-plugin');

plugins: [
    new AutoDllPlugin({
            inject: true, // will inject the DLL bundles to html
            context: path.join(__dirname, '..'),
            filename: '[name]_[hash].dll.js',
            path: 'res/js',
            plugins: mode === 'online' ? [
                new UglifyJsPlugin({
                    uglifyOptions: {
                        compress: {
                            warnings: false
                        }
                    },
                    sourceMap: config.build.productionSourceMap,
                    parallel: true
                })
            ] : [],
            entry: {
                vendor: ['vue/dist/vue.esm.js', 'vuex', 'axios', 'vue-router', 'babel-polyfill', 'lodash']
            }
     })
]

一次基于 dll 的 webpack 构建过程优化,便大功告成了!

Happypack——将 loader 由单进程转为多进程

大家知道,webpack 是单线程的,就算此刻存在多个任务,你也只能排队一个接一个地等待处理。这是 webpack 的缺点,好在我们的 CPU 是多核的,Happypack 会充分释放 CPU 在多核并发方面的优势,帮我们把任务分解给多个子进程去并发执行,大大提升打包效率。

HappyPack 的使用方法也非常简单,只需要我们把对 loader 的配置转移到 HappyPack 中去就好,我们可以手动告诉 HappyPack 我们需要多少个并发的进程

const HappyPack = require('happypack')
// 手动创建进程池
const happyThreadPool =  HappyPack.ThreadPool({ size: os.cpus().length })

module.exports = {
  module: {
    rules: [
      ...
      {
        test: /\.js$/,
        // 问号后面的查询参数指定了处理这类文件的HappyPack实例的名字
        loader: 'happypack/loader?id=happyBabel',
        ...
      },
    ],
  },
  plugins: [
    ...
    new HappyPack({
      // 这个HappyPack的“名字”就叫做happyBabel,和楼上的查询参数遥相呼应
      id: 'happyBabel',
      // 指定进程池
      threadPool: happyThreadPool,
      loaders: ['babel-loader?cacheDirectory']
    })
  ],
}

构建结果体积压缩

文件结构可视化,找出导致体积过大的原因

这里为大家介绍一个非常好用的包组成可视化工具——webpack-bundle-analyzer,配置方法和普通的 plugin 无异,它会以矩形树图的形式将包内各个模块的大小和依赖关系呈现出来,格局如官方所提供这张图所示:

!------)
image.png
在使用时,我们只需要将其以插件的形式引入

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

拆分资源

这点仍然围绕 DllPlugin 展开

删除冗余代码

一个比较典型的应用,就是 Tree-Shaking
基于 import/export 语法,Tree-Shaking 可以在编译的过程中获悉哪些模块并没有真正被使用,这些没用的代码,在最后打包的时候会被去除。
适合用来处理模块级别的冗余代码。至于粒度更细的冗余代码的去除,往往会被整合进 JS 或 CSS 的压缩或分离过程中。
这里我们以当下接受度较高的 UglifyJsPlugin 为例,看一下如何在压缩过程中对碎片化的冗余代码(如 console 语句、注释等)进行自动化删除:

const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
  plugins: [
    new UglifyJsPlugin({
      // 允许并发
      parallel: true,
      // 开启缓存
      cache: true,
      compress: {
        // 删除所有的console语句    
        drop_console: true,
        // 把使用多次的静态值自动定义为变量
        reduce_vars: true,
      },
      output: {
        // 不保留注释
        comment: false,
        // 使输出的代码尽可能紧凑
        beautify: false
      }
    })
  ]
}

webpack4 中,我们是通过配置 optimization.minimize 与 optimization.minimizer 来自定义压缩相关的操作的。

按需加载

  • 一次不加载完所有的文件内容,只加载此刻需要用到的那部分(会提前做拆分)
  • 当需要更多内容时,再对用到的内容进行即时加载

当我们不需要按需加载的时候,我们的代码是这样的:

import BugComponent from '../pages/BugComponent'
...
<Route path="/bug" component={BugComponent}>

为了开启按需加载,我们要稍作改动。
首先 webpack 的配置文件要走起来:

output: {
 path: path.join(__dirname, '/../dist'),
 filename: 'app.js',
 publicPath: defaultSettings.publicPath,
 // 指定 chunkFilename
 chunkFilename: '[name].[chunkhash:5].chunk.js',
},

路由处的代码也要做一下配合;

const getComponent => (location, cb) {
  require.ensure([], (require) => {
    cb(null, require('../pages/BugComponent').default)
  }, 'bug')
},

<Route path="/bug" getComponent={getComponent}>

核心就是这个方法:

require.ensure(dependencies, callback, chunkName)

这是一个异步的方法,webpack 在打包时,BugComponent 会被单独打成一个文件,只有在我们跳转 bug 这个路由的时候,这个异步方法的回调才会生效,才会真正地去获取 BugComponent 的内容。这就是按需加载。

按需加载的粒度,还可以继续细化,细化到更小的组件、细化到某个功能点,都是 ok 的。

Gzip 压缩原理

开启 Gzip。
具体的做法非常简单,只需要你在你的 request headers 中加上这么一句:

accept-encoding:gzip

我们前端关系更密切的话题:HTTP 压缩。

HTTP 压缩是一种内置到网页服务器和网页客户端中以改进传输速度和带宽利用率的方式。在使用 HTTP 压缩的情况下,HTTP 数据在从服务器发送前就已压缩:兼容的浏览器将在下载所需的格式前宣告支持何种方法给服务器;不支持压缩方法的浏览器将下载未经压缩的数据。最常见的压缩方案包括 Gzip 和 Deflate。

HTTP 压缩就是以缩小体积为目的,对 HTTP 内容进行重新编码的过程
Gzip 的内核就是 Deflate,目前我们压缩文件用得最多的就是 Gzip。可以说,Gzip 就是 HTTP 压缩的经典例题。

该不该用 Gzip

压缩 Gzip,服务端要花时间;解压 Gzip,浏览器要花时间。中间节省出来的传输时间,真的那么可观吗?
我们处理的都是具备一定规模的项目文件。实践证明,这种情况下压缩和解压带来的时间开销相对于传输过程中节省下的时间开销来说,可以说是微不足道的。

Gzip 是万能的吗

首先要承认 Gzip 是高效的,压缩后通常能帮我们减少响应 70% 左右的大小。

但它并非万能。Gzip 并不保证针对每一个文件的压缩都会使其变小。

Gzip 压缩背后的原理,是在一个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。根据这个原理,文件中代码的重复率越高,那么压缩的效率就越高,使用 Gzip 的收益也就越大。反之亦然。

webpack 的 Gzip 和服务端的 Gzip

一般来说,Gzip 压缩是服务器的活儿:服务器了解到我们这边有一个 Gzip 压缩的需求,它会启动自己的 CPU 去为我们完成这个任务。而压缩文件这个过程本身是需要耗费时间的,大家可以理解为我们以服务器压缩的时间开销和 CPU 开销(以及浏览器解析压缩文件的开销)为代价,省下了一些传输过程中的时间开销。

既然存在着这样的交换,那么就要求我们学会权衡。服务器的 CPU 性能不是无限的,如果存在大量的压缩需求,服务器也扛不住的。服务器一旦因此慢下来了,用户还是要等。Webpack 中 Gzip 压缩操作的存在,事实上就是为了在构建过程中去做一部分服务器的工作,为服务器分压。

因此,这两个地方的 Gzip 压缩,谁也不能替代谁。它们必须和平共处,好好合作。作为开发者,我们也应该结合业务压力的实际强度情况,去做好这其中的权衡。

图片优化——质量与性能的博弈

图片是电商平台的重要资源,甚至有人说“做电商就是做图片”。

就图片这块来说,与其说我们是在做“优化”,不如说我们是在做“权衡”。因为我们要做的事情,就是去压缩图片的体积(或者一开始就选取体积较小的图片格式)。但这个优化操作,是以牺牲一部分成像质量为代价的。因此我们的主要任务,是尽可能地去寻求一个质量与性能之间的平衡点。
时下应用较为广泛的 Web 图片格式有 JPEG/JPG、PNG、WebP、Base64、SVG 等
不谈业务场景的选型都是耍流氓

在计算机中,像素用二进制数来表示。不同的图片格式中像素与二进制位数之间的对应关系是不同的。一个像素对应的二进制位数越多,它可以表示的颜色种类就越多,成像效果也就越细腻,文件体积相应也会越大。
一个二进制位表示两种颜色(0|1 对应黑|白),如果一种图片格式对应的二进制位数有 n 个,那么它就可以呈现 2^n 种颜色。

JPEG/JPG

关键字:有损压缩、体积小、加载快、不支持透明

JPG 的优点

JPG 最大的特点是有损压缩
。这种高效的压缩算法使它成为了一种非常轻巧的图片格式。另一方面,即使被称为“有损”压缩,JPG的压缩方式仍然是一种高质量的压缩方式:当我们把图片体积压缩至原有体积的 50% 以下时,JPG 仍然可以保持住 60% 的品质。此外,JPG 格式以 24 位存储单个图,可以呈现多达 1600 万种颜色,足以应对大多数场景下对色彩的要求,这一点决定了它压缩前后的质量损耗并不容易被我们人类的肉眼所察觉
JPG 适用于呈现色彩丰富的图片,在我们日常开发中,JPG 图片经常作为大的背景图、轮播图或 Banner 图出现。

JPG 的缺陷

有损压缩在上文所展示的轮播图上确实很难露出马脚,但当它处理矢量图形Logo 等线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显。

此外,JPEG 图像不支持透明度处理,透明图片需要召唤 PNG 来呈现。

PNG-8 与 PNG-24

关键字:无损压缩、质量高、体积大、支持透明

PNG 的优点

PNG(可移植网络图形格式)是一种无损压缩的高保真的图片格式。8 和 24,这里都是二进制数的位数。按照我们前置知识里提到的对应关系,8 位的 PNG 最多支持 256 种颜色,而 24 位的可以呈现约 1600 万种颜色。
PNG 图片具有比 JPG 更强的色彩表现力,对线条的处理更加细腻,对透明度有良好的支持。它弥补了上文我们提到的 JPG 的局限性,唯一的 BUG 就是体积太大
前面我们提到,复杂的、色彩层次丰富的图片,用 PNG 来处理的话,成本会比较高,我们一般会交给 JPG 去存储。

考虑到 PNG 在处理线条和颜色对比度方面的优势,我们主要用它来呈现小的 Logo、颜色简单且对比强烈的图片或背景等。

SVG

文本文件、体积小、不失真、兼容性好

SVG(可缩放矢量图形)是一种基于 XML 语法的图像格式。它和本文提及的其它图片种类有着本质的不同:SVG 对图像的处理不是基于像素点,而是是基于对图像的形状描述。

和性能关系最密切的一点就是:SVG 与 PNG 和 JPG 相比,文件体积更小,可压缩性更强

当然,作为矢量图,它最显著的优势还是在于图片可无限放大而不失真这一点上。这使得 SVG 即使是被放到视网膜屏幕上,也可以一如既往地展现出较好的成像品质——1 张 SVG 足以适配 n 种分辨率。

此外,SVG 是文本文件。我们既可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件(SVG 文件在使用上与普通图片文件无异)。这使得 SVG 文件可以被非常多的工具读取和修改,具有较强的灵活性

SVG 的局限性主要有两个方面,一方面是它的渲染成本比较高,这点对性能来说是很不利的。另一方面,SVG 存在着其它图片格式所没有的学习成本(它是可编程的)

SVG 的使用方式与应用场景

SVG 是文本文件,我们既可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件(SVG 文件在使用上与普通图片文件无异)。

  • 将 SVG 写入 HTML:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
    <svg xmlns="http://www.w3.org/2000/svg"   width="200" height="200">
        <circle cx="50" cy="50" r="50" />
    </svg>
</body>
</html>

将 SVG 写入独立文件后引入 HTML:

<img data-original="文件名.svg" alt="">

在实际开发中,我们更多用到的是后者。很多情况下设计师会给到我们 SVG 文件,就算没有设计师,我们还有非常好用的 在线矢量图形库。对于矢量图,我们无须深究过多,只需要对其核心特性有所掌握、日后在应用时做到有迹可循即可。

最经典的小图标解决方案——雪碧图(CSS Sprites)

一种将小图标和背景图像合并到一张图片上,然后利用 CSS 的背景定位来显示其中的每一部分的技术。

被运用于众多使用大量小图标的网页应用之上。它可取图像的一部分来使用,使得使用一个图像文件替代多个小文件成为可能。相较于一个小图标一个图像文件,单独一张图片所需的 HTTP 请求更少,对内存和带宽更加友好。

Base64

不难看出,每次加载图片,都是需要单独向服务器请求这个图片对应的资源的——这也就意味着一次 HTTP 请求的开销。
Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,我们可以直接将编码结果写入 HTML 或者写入 CSS,从而减少 HTTP 请求的次数

按照一贯的思路,我们加载图片需要把图片链接写入 img 标签:

<img data-original="https://user-gold-cdn.xitu.io/2018/9/15/165db7e94699824b?w=22&h=22&f=png&s=3680">

浏览器就会针对我们的图片链接去发起一个资源请求.

但是如果我们对这个图片进行 Base64 编码,我们会得到一个这样的字符串:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAMJGlDQ1BJQ0MgUHJvZmlsZQAASImVlwdUU8kagOeWJCQktEAEpITeBCnSpdfQpQo2QhJIKDEkBBU7uqjgWlARwYquitjWAshiw14Wwd4fiKgo62LBhsqbFNDV89477z9n7v3yzz9/mcydMwOAehxbJMpFNQDIExaI48MCmeNT05ikR4AECIAKRgEamyMRBcTFRQEoQ+9/yrubAJG9r9nLfP3c/19Fk8uTcABA4iBncCWcPMiHAMDdOCJxAQCEXqg3m1YggkyEWQJtMUwQsrmMsxTsIeMMBUfJbRLjgyCnA6BCZbPFWQCoyfJiFnKyoB+1pZAdhVyB

字符串比较长,我们可以直接用这个字符串替换掉上文中的链接地址。你会发现浏览器原来是可以理解这个字符串的,它自动就将这个字符串解码为了一个图片,而不需再去发送 HTTP 请求

Base64 的应用场景

上面这个实例,其实源自我们 掘金 网站 Header 部分的搜索栏 Logo:
Base64 编码后,图片大小会膨胀为原文件的 4/3(这是由 Base64 的编码原理决定的)。如果我们把大图也编码到 HTML 或 CSS 文件中,后者的体积会明显增加,即便我们减少了 HTTP 请求,也无法弥补这庞大的体积带来的性能开销,得不偿失。

在传输非常小的图片的时候,Base64 带来的文件体积膨胀、以及浏览器解析 Base64 的时间开销,与它节省掉的 HTTP 请求开销相比,可以忽略不计,这时候才能真正体现出它在性能方面的优势。

因此,Base64 并非万全之策,我们往往在一张图片满足以下条件时会对它应用 Base64 编码:

  • 图片的实际尺寸很小(大家可以观察一下掘金页面的 Base64 图,几乎没有超过 2kb 的)
  • 图片无法以雪碧图的形式与其它小图结合(合成雪碧图仍是主要的减少 HTTP 请求的途径,Base64 是雪碧图的补充)
  • 图片的更新频率非常低(不需我们重复编码和修改文件内容,维护成本较低)

    Base64 编码工具推荐

    这里最推荐的是利用 webpack 来进行 Base64 的编码——webpack 的 url-loader 非常聪明,它除了具备基本的 Base64 转码能力,还可以结合文件大小,帮我们判断图片是否有必要进行 Base64 编码。

除此之外,市面上免费的 Base64 编解码工具种类是非常多样化的,有很多网站都提供在线编解码的服务,大家选取自己认为顺手的工具就好。

WebP

WebP 像 JPEG 一样对细节丰富的图片信手拈来,像 PNG 一样支持透明,像 GIF 一样可以显示动态图片——它集多种图片文件格式的优点于一身。

与 PNG 相比,WebP 无损图像的尺寸缩小了 26%。在等效的 SSIM 质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。

无损 WebP 支持透明度(也称为 alpha 通道),仅需 22% 的额外字节。对于有损 RGB 压缩可接受的情况,有损 WebP 也支持透明度,与 PNG 相比,通常提供 3 倍的文件大小。
WebP 纵有千般好 都逃不开兼容性的大坑

此外,WebP 还会增加服务器的负担——和编码 JPG 文件相比,编码同样质量的 WebP 文件会占用更多的计算资源。

WebP 的应用场景

现在限制我们使用 WebP 的最大问题不是“这个图片是否适合用 WebP 呈现”的问题,而是“浏览器是否允许 WebP”的问题,即我们上文谈到的兼容性问题。具体来说,一旦我们选择了 WebP,就要考虑在 Safari 等浏览器下它无法显示的问题,也就是说我们需要准备 PlanB,准备降级方案。

目前真正把 WebP 格式落地到网页中的网站并不是很多,这其中淘宝首页对 WebP 兼容性问题的处理方式就非常有趣。我们可以打开 Chrome 的开发者工具搜索其源码里的 WebP 关键字

<img data-original="//img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg_.webp" alt="手机app - 聚划算" class="app-icon">

.webp 前面,还跟了一个 .jpg 后缀!
这个图片应该至少存在 jpg 和 webp 两种格式,程序会根据浏览器的型号、以及该型号是否支持 WebP 这些信息来决定当前浏览器显示的是 .webp 后缀还是 .jpg 后缀。带着这个预判,我们打开并不支持 WebP 格式的 Safari 来进入同样的页面,再次搜索 WebP 关键字:
Safari 提示我们找不到,这也是情理之中。我们定位到刚刚示例的 WebP 图片所在的元素,查看一下它在 Safari 里的图片链接

<img data-original="//img.alicdn.com/tps/i4/TB1CKSgIpXXXXccXXXX07tlTXXX-200-200.png_60x60.jpg" alt="手机app - 聚划算" class="app-icon">

在 Safari 中的后缀从 .webp 变成了 .jpg!
站点确实是先进行了兼容性的预判,在浏览器环境支持 WebP 的情况下,优先使用 WebP 格式,否则就把图片降级为 JPG 格式(本质是对图片的链接地址作简单的字符串切割)。
此外,还有另一个维护性更强、更加灵活的方案——把判断工作交给后端,由服务器根据 HTTP 请求头部的 Accept 字段来决定返回什么格式的图片。当 Accept 字段包含 image/webp 时,就返回 WebP 格式的图片,否则返回原图。这种做法的好处是,当浏览器对 WebP 格式图片的兼容支持发生改变时,我们也不用再去更新自己的兼容判定代码,只需要服务端像往常一样对 Accept 字段进行检查即可。

由此也可以看出,我们 WebP 格式的局限性确实比较明显,如果决定使用 WebP,兼容性处理是必不可少的

浏览器缓存机制与缓存策略

缓存可以提高网络IO消耗 提高访问速度
通过网络获取内容既速度缓慢又开销巨大。较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。因此,缓存并重复利用之前获取的资源的能力成为性能优化的一个关键方面。
浏览器缓存机制有四个方面,它们按照获取资源时请求的优先级依次排列如下

  1. Memory Cache
  2. Service Worker Cache
  3. HTTP Cache
  4. Push Cache

HTTP 缓存机制

分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。

强缓存是利用 http 头中的 Expires 和 Cache-Control 两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。

命中强缓存的情况下,返回的 HTTP 状态码为 200

强缓存的实现:从 expires 到 cache-control

实现强缓存,过去我们一直用 expires。
当服务器返回响应时,在 Response Headers 中将过期时间写入 expires 字段。像这样:

expires: Wed, 11 Sep 2019 16:12:18 GMT

expires 是一个时间戳,接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。

expires 是有问题的,它最大的问题在于对“本地时间”的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期。
考虑到 expires 的局限性,HTTP1.1 新增了 Cache-Control 字段来完成 expires 的任务。

expires 能做的事情,Cache-Control 都能做;expires 完成不了的事情,Cache-Control 也能做。因此,Cache-Control 可以视作是 expires 的完全替代方案。在当下的前端实践里,我们继续使用 expires 的唯一目的就是向下兼容。

现在我们给 Cache-Control 字段一个特写:

cache-control: max-age=31536000

通过 max-age 来控制资源的有效期。max-age 不是一个时间戳,而是一个时间长度。在本例中,max-age 是 31536000 秒,它意味着该资源在 31536000 秒以内都是有效的,完美地规避了时间戳带来的潜在问题。
Cache-Control 相对于 expires 更加准确,它的优先级也更高。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。
Cache-Control 的神通,可不止于这一个小小的 max-age。如下的用法也非常常见

cache-control: max-age=3600, s-maxage=31536000

s-maxage 优先级高于 max-age,两者同时出现时,优先考虑 s-maxage。如果 s-maxage 未过期,则向代理服务器请求其缓存内容。
在项目不是特别大的场景下,max-age 足够用了。但在依赖各种代理的大型架构中,我们不得不考虑代理服务器的缓存问题。s-maxage 就是用于表示 cache 服务器上(比如 cache CDN)的缓存的有效时间的,并只对 public 缓存有效。
那么什么是 public 缓存呢

public 与 private

public 与 private 是针对资源是否能够被代理服务缓存而存在的一组对立概念。
如果我们为资源设置了 public,那么它既可以被浏览器缓存,也可以被代理服务器缓存;如果我们设置了 private,则该资源只能被浏览器缓存。private 为默认值。但多数情况下,public 并不需要我们手动设置,比如有很多线上网站的 cache-control 是这样的:
image.png
设置了 s-maxage,没设置 public,那么 CDN 还可以缓存这个资源吗?答案是肯定的。因为明确的缓存信息(例如“max-age”)已表示响应是可以缓存的。

no-store与no-cache

no-cache 绕开了浏览器:我们为资源设置了 no-cache 后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期

no-store 比较绝情,顾名思义就是不使用任何缓存策略。在 no-cache 的基础上,它连服务端的缓存确认也绕开了,只允许你直接向服务端发送请求、并下载完整的响应。

协商缓存:浏览器与服务器合作之下的缓存策略

协商缓存依赖于服务端与浏览器之间的通信。
协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。
如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304(如下图)
image.png

协商缓存的实现:从 Last-Modified 到 Etag

Last-Modified 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers 返回:

Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT

随后我们每次请求时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值:

If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT

服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified 值;否则,返回如上图的 304 响应,Response Headers 不会再添加 Last-Modified 字段。
使用 Last-Modified 存在一些弊端,这其中最常见的就是这样两个场景:
1 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。
2 当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。

这两个场景其实指向了同一个 bug——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified 的补充出现了。

Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化
Etag 和 Last-Modified 类似,当首次请求时,我们会在响应头里获取到一个最初的标识符字符串,举个🌰,它可以是这样的:

ETag: W/"2a3b-1602480f459"

那么下一次请求时,请求头里就会带上一个值相同的、名为 if-None-Match 的字符串供服务端比对了:

If-None-Match: W/"2a3b-1602480f459"

Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化存在。
Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。
image.png
解读一下这张流程图
当我们的资源内容不可复用时,直接为 Cache-Control 设置 no-store,拒绝一切形式的缓存;否则考虑是否每次都需要向服务器进行缓存有效确认,如果需要,那么设 Cache-Control 的值为 no-cache;否则考虑该资源是否可以被代理服务器缓存,根据其结果决定是设置为 private 还是 public;然后考虑该资源的过期时间,设置对应的 max-age 和 s-maxage 值;最后,配置协商缓存需要用到的 Etag、Last-Modified 等参数。

MemoryCache

MemoryCache,是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存。
内存缓存是快的,也是“短命”的。它和渲染进程“生死相依”,当进程结束后,也就是 tab 关闭以后,内存里的数据也将不复存在。

资源存不存内存,浏览器秉承的是“节约原则”。我们发现,Base64 格式的图片,几乎永远可以被塞进 memory cache,这可以视作浏览器为节省渲染开销的“自保行为”;此外,体积不大的 JS、CSS 文件,也有较大地被写入内存的几率——相比之下,较大的 JS、CSS 文件就没有这个待遇了,内存资源是有限的,它们往往被直接甩进磁盘

Service Worker Cache

Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。
这样独立的个性使得 Service Worker 的“个人行为”无法干扰页面的性能,这个“幕后工作者”可以帮我们实现离线缓存、消息推送和网络代理等功能。
我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache
Service Worker 的生命周期包括 install、active、working 三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。
Service Worker 如何为我们实现离线缓存(注意看注释):入口文件中插入这样一段 JS 代码,用以判断和引入 Service Worker:

window.navigator.serviceWorker.register('/test.js').then(()=>{
console.log('注册成功')
}).catch((error)=>{
    console.log('注册失败')
})

在 test.js 中,我们进行缓存的处理。假设我们需要缓存的文件分别是 test.html,test.css 和 test.js:

 self.addEventListener('install',event=>{
          event.waitUntill(
            // 考虑到缓存也需要更新, open内传入的参数为缓存的版本号
            caches.open('test-v1').then(cache=>{
              return cache.addAll([
                //此处传入指定的需缓存的文件名
                '/test.html',
                '/test.css',
                'test.js'
              ])
            })
          )
        })
        //Service Worker会监听所有的网络请求,网络请求的产生触发的是fetch事件,我们可以在其对应的监听函数中实现对请求的拦截
        //进而判断是否对应到该请求的缓存 实现从Service Worker中取缓存的目的
        self.addEventListener('fetch',event=>{
          event.respondWith(
            //尝试匹配该请求对应的缓存值
            caches.match(event.request).then(res=>{
              //如果匹配到了,调用Server Worker缓存
              if(res){
                return res
              }
              //如果没有匹配到  向服务器发起这个资源请求
              return fetch(event.request).then(response=>{
                if(!response||response.status!==200){
                  return response
                }
                //请求成功的话,将请求缓存起来
                caches.open('test-v1').then((cache)=>{
                  cache.put(event.request,response)
                })
                return response.clone()
              })
            })
          )
        })

Server Worker 对协议是有要求的,必须以 https 协议为前提。

Push Cache

https://jakearchibald.com/201...

Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。这块的知识比较新,应用也还处于萌芽阶段,
但应用范围有限不代表不重要——HTTP2 是趋势、是未来。

*Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。

*Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
*不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。

本地存储——从 Cookie 到 Web Storage、IndexDB

从 Cookie 说起

HTTP 协议是一个无状态协议,服务器接收客户端的请求,返回一个响应 服务器并没有记录下关于客户端的任何信息。
Cookie 说白了就是一个存储在浏览器里的一个小小的文本文件,它附着在 HTTP 请求上,在浏览器和服务器之间“飞来飞去”。它可以携带用户信息,当服务器检查 Cookie 的时候,便可以获取到客户端的状态。

Cookie的性能劣势

Cookie 不够大

Cookie 是有体积上限的,它最大只能有 4KB。当 Cookie 超过 4KB 时,它将面临被裁切的命运。这样看来,Cookie 只能用来存取少量的信息。

过量的 Cookie 会带来巨大的性能浪费

Cookie 是紧跟域名的。我们通过响应头里的 Set-Cookie 指定要存储的 Cookie 值。默认情况下,domain 被设置为设置 Cookie 页面的主机名,我们也可以手动设置 domain 的值:

Set-Cookie: name=xiuyan; domain=xiuyan.me

同一个域名下的所有请求,都会携带 Cookie

请求一张图片或者一个 CSS 文件,我们也要携带一个 Cookie 跑来跑去(关键是 Cookie 里存储的信息我现在并不需要),这是一件多么劳民伤财的事情。Cookie 虽然小,请求却可以有很多,随着请求的叠加,这样的不必要的 Cookie 带来的开销将是无法想象的。

Web Storage

Web Storage 是 HTML5 专门为浏览器存储而提供的数据存储机制。它又分为 Local Storage 与 Session Storage。
两者的区别在于生命周期作用域的不同。

  • 生命周期:Local Storage 是持久化的本地存储,存储在其中的数据是永远不会过期的,使其消失的唯一办法是手动删除;而 Session Storage 是临时性的本地存储,它是会话级别的存储,当会话结束(页面被关闭)时,存储内容也随之被释放。
  • 作用域:Local Storage、Session Storage 和 Cookie 都遵循同源策略。但 Session Storage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 Session Storage 内容便无法共享。

Web Storage 的特性

  • 存储容量大: Web Storage 根据浏览器的不同,存储容量可以达到 5-10M 之间。
  • 仅位于浏览器端,不与服务端发生通信。
  • Web Storage 核心 API 使用示例

Web Storage 保存的数据内容和 Cookie 一样,是文本内容,以键值对的形式存在。Local Storage 与 Session Storage 在 API 方面无异,这里我们以 localStorage 为例:

  • 存储数据:setItem()
localStorage.setItem('user_name', 'xiuyan')
  • 读取数据: getItem()
localStorage.getItem('user_name')
  • 删除某一键名对应的数据: removeItem()
localStorage.removeItem('user_name')
  • 清空数据记录:clear()
localStorage.clear()

应用场景

倾向于用它来存储一些内容稳定的资源。比如图片内容丰富的电商网站会用它来存储 Base64 格式的图片字符串:
有的网站还会用它存储一些不经常更新的 CSS、JS 等静态资源。

Session Storage

Session Storage 更适合用来存储生命周期和它同步的会话级别的信息。这些信息只适用于当前会话,当你开启新的会话时,它也需要相应的更新或释放。比如微博的 Session Storage 就主要是存储你本次会话的浏览足迹:
image.png
lasturl 对应的就是你上一次访问的 URL 地址,这个地址是即时的。当你切换 URL 时,它随之更新,当你关闭页面时,留着它也确实没有什么意义了,干脆释放吧。这样的数据用 Session Storage 来处理再合适不过
Web Storage 是一个从定义到使用都非常简单的东西。它使用键值对的形式进行存储,这种模式有点类似于对象,却甚至连对象都不是——它只能存储字符串,要想得到对象,我们还需要先对字符串进行一轮解析。

说到底,Web Storage 是对 Cookie 的拓展,它只能用于存储少量的简单数据。当遇到大规模的、结构复杂的数据时,Web Storage 也爱莫能助了。这时候我们就要清楚我们的终极大 boss——IndexDB!

终极形态:IndexDB

IndexDB 是一个运行在浏览器上的非关系型数据库。既然是数据库了,那就不是 5M、10M 这样小打小闹级别了。理论上来说,IndexDB 是没有存储上限的(一般来说不会小于 250M)。它不仅可以存储字符串,还可以存储二进制数据。

遵循 MDN 推荐的操作模式 操作一个基本的 IndexDB 使用流程
1 打开/创建一个 IndexDB 数据库(当该数据库不存在时,open 方法会直接创建一个名为 xiaoceDB 新数据库)。

   //后面的回调中  我们可以通过event.target.result拿到数据库实例
       let db
       //参数1位数据库名  参数2为版本号
       const request = window.indexedDB.open('xiaoceDB',1)
       //使用IndexDB失败时的监听函数
       request.onerror = function(event){
         console.log('无法使用IndexDB');
       }
       //成功
       request.onsuccess = function(event){
         //此处就可以获取到db实例
         db = event.target.result
         console.log('您打开了IndexDB');
       }

2 创建一个object store(object store对标到数据库中的表单位)

 //onupgradeneeded事件会在初始化数据库/版本发生更新时调用,我们在它的监听函数中创建object store
              request.onupgradeneeded = function(event){
                let objectStore
                //如果同名表未被创建过 则新建test表
                if(!db.objectStoreNames.contains('test')){
                  objectStore = db.createObjectStore('test',{keyPath:'id'})
                }
              }

3 构建一个事务来执行一些数据库操作,像增加或提取数据等。

 //创建事务  指定表格名称和读写功能
             const transaction = db.transaction(["test"],"readwrite")
            //  拿到Object Store对象
            const objectStore = transaction.objectStore("test")
            //向表格写入数据
            objectStore.add({id:1,name:'xiuyan'})

4 通过监听正确类型的事件以等待操作完成。

 // 操作完成时的监听函数
            transaction.oncomplete = function(event){
              console.log('操作完成')
            }
            // 操作失败时的监听函数
            transaction.onerror = function(event){
              console.log('这里有一个error')
            }

IndexDB 的应用场景

在 IndexDB 中,我们可以创建多个数据库,一个数据库中创建多张表,一张表中存储多条数据——这足以 hold 住复杂的结构性数据。IndexDB 可以看做是 LocalStorage 的一个升级,当数据的复杂度和规模上升到了 LocalStorage 无法解决的程度,我们毫无疑问可以请出 IndexDB 来帮忙。

浏览器缓存/存储技术的出现和发展,为我们的前端应用带来了无限的转机。近年来基于缓存/存储技术的第三方库层出不绝,此外还衍生出了 PWA 这样优秀的 Web 应用模型。可以说,现代前端应用,尤其是移动端应用,之所以可以发展到在体验上叫板 Native 的地步,主要就是仰仗缓存/存储立下的汗马功劳

CDN 的缓存与回源机制解析

CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器。这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响

缓存、本地存储带来的性能提升,是不是只能在“获取到资源并把它们存起来”这件事情发生之后?也就是说,首次请求资源的时候,这些招数都是救不了我们的。要提升首次请求的响应能力,我们还需要借助 CDN 的能力

CDN 如何工作

*假设我的根服务器在杭州
此时有一位北京的用户向我请求资源。在网络带宽小、用户访问量大的情况下,杭州的这一台服务器或许不那么给力,不能给用户非常快的响应速度。于是我灵机一动,把这批资源 copy 了一批放在北京的机房里。当用户请求资源时,就近请求北京的服务器,北京这台服务器低头一看,这个资源我存了,离得这么近,响应速度肯定噌噌的!那如果北京这台服务器没有 copy 这批资源呢?它会再向杭州的根服务器去要这个资源。在这个过程中,北京这台服务器就扮演着 CDN 的角色。*

CDN的核心功能特写

CDN 的核心点有两个,一个是缓存,一个是回源

“缓存”就是说我们把资源 copy 一份到 CDN 服务器上这个过程,“回源”就是说 CDN 发现自己没有这个资源(一般是缓存的数据过期了),转头向根服务器(或者它的上层服务器)去要这个资源的过程。

CDN 与前端性能优化

CDN 往往被用来存放静态资源。上文中我们举例所提到的“根服务器”本质上是业务服务器,它的核心任务在于生成动态页面或返回非纯静态页面,这两种过程都是需要计算的。业务服务器仿佛一个车间,车间里运转的机器轰鸣着为我们产出所需的资源;相比之下,CDN 服务器则像一个仓库,它只充当资源的“栖息地”和“搬运工”。
所谓“静态资源”,就是像 JS、CSS、图片等不需要业务服务器进行计算即得的资源。而“动态资源”,顾名思义是需要后端实时动态生成的资源,较为常见的就是 JSP、ASP 或者依赖服务端渲染得到的 HTML 页面。
什么是“非纯静态资源”呢?它是指需要服务器在页面之外作额外计算的 HTML 页面。具体来说,当我打开某一网站之前,该网站需要通过权限认证等一系列手段确认我的身份、进而决定是否要把 HTML 页面呈现给我。这种情况下 HTML 确实是静态的,但它和业务服务器的操作耦合,我们把它丢到CDN 上显然是不合适的。

CDN 的实际应用

静态资源本身具有访问频率高、承接流量大的特点,因此静态资源加载速度始终是前端性能的一个非常关键的指标。CDN 是静态资源提速的重要手段,在许多一线的互联网公司,“静态资源走 CDN”并不是一个建议,而是一个规定。
比如以淘宝为代表的阿里系产品,就遵循着这个“规定”。

打开淘宝首页,我们可以在 Network 面板中看到,“非纯静态”的 HTML 页面,是向业务服务器请求来的:
image.png
我们点击 preview,可以看到业务服务器确实是返回给了我们一个尚未被静态资源加持过的简单 HTML 页面,所有的图片内容都是先以一个 div 占位:
image.png

相应地,我们随便点开一个静态资源,可以看到它都是从 CDN 服务器上请求来的。

比如说图片:
image.png

再比如 JS、CSS 文件:
image.png

CDN 优化细节

如何让 CDN 的效用最大化?这又是需要前后端程序员一起思考的庞大命题。它涉及到 CDN 服务器本身的性能优化、CDN 节点的地址选取等。谈离前端最近的这部分细节:CDN 的域名选取。
淘宝首页的例子,我们注意到业务服务器的域名是这个:

www.taobao.com

而 CDN 服务器的域名是这个

g.alicdn.com

我们讲到 Cookie 的时候,为了凸显 Local Storage 的优越性,曾经提到过
同一个域名下的请求会不分青红皂白地携带 Cookie,而静态资源往往并不需要 Cookie 携带什么认证信息。把静态资源和主页面置于不同的域名下,完美地避免了不必要的 Cookie 的出现!
看起来是一个不起眼的小细节,但带来的效用却是惊人的。以电商网站静态资源的流量之庞大,如果没把这个多余的 Cookie 拿下来,不仅用户体验会大打折扣,每年因性能浪费带来的经济开销也将是一个非常恐怖的数字。

如此看来,性能优化还真是要步步为营!

服务端渲染的探索与实践、

服务端渲染的运行机制

客户端渲染

客户端渲染模式下,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM。这种特性使得客户端渲染的源代码总是特别简洁,

<!doctype html>
<html>
  <head>
    <title>我是客户端渲染的页面</title>
  </head>
  <body>
    <div id='root'></div>
    <script data-original='index.js'></script>
  </body>
</html>

根节点下到底是什么内容呢?你不知道,我不知道,只有浏览器把 index.js 跑过一遍后才知道,这就是典型的客户端渲染。

页面上呈现的内容,你在 html 源文件里里找不到——这正是它的特点。

服务端渲染

服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。
使用服务端渲染的网站,可以说是“所见即所得”,页面上呈现的内容,我们在 html 源文件里也能找到

比如知乎就是典型的服务端渲染案例:

服务端渲染解决了什么性能问题

事实上,很多网站是出于效益的考虑才启用服务端渲染,性能倒是在其次。
假设 A 网站页面中有一个关键字叫“前端性能优化”,这个关键字是 JS 代码跑过一遍后添加到 HTML 页面中的。那么客户端渲染模式下,我们在搜索引擎搜索这个关键字,是找不到 A 网站的——搜索引擎只会查找现成的内容,不会帮你跑 JS 代码。A 网站的运营方见此情形,感到很头大:搜索引擎搜不出来,用户找不到我们,谁还会用我的网站呢?为了把“现成的内容”拿给搜索引擎看,A 网站不得不启用服务端渲染。

但性能在其次,不代表性能不重要。服务端渲染解决了一个非常关键的性能问题——首屏加载速度过慢。在客户端渲染模式下,我们除了加载 HTML,还要等渲染所需的这部分 JS 加载完,之后还得把这部分 JS 在浏览器上再跑一遍。这一切都是发生在用户点击了我们的链接之后的事情,在这个过程结束之前,用户始终见不到我们网页的庐山真面目,也就是说用户一直在等!相比之下,服务端渲染模式下,服务器给到客户端的已经是一个直接可以拿来呈现给用户的网页,中间环节早在服务端就帮我们做掉了,用户岂不“美滋滋”?

服务端渲染的应用实例

先来看一下在一个 React 项目里,服务端渲染是怎么实现的。本例中,我们使用 Express 搭建后端服务。

项目中有一个叫做 VDom 的 React 组件,它的内容如下。

VDom.js:

 import React from 'react'
           const VDom = ()=>{
             return <div>我是一个被渲染为真是DOM的虚拟DOM</div>
           }
           export default VDom

在服务端的入口文件中,我引入这个组件,对它进行渲染:

 import express from 'express'
        import React from 'react'
        import {renderToString} from 'react-dom/server'
        import VDom from './VDom'
        // 创建一个express应用
        const app = express()
        //renderToString 是把虚拟DOM转化为真实DOM内容
        const Page = `
         <html>
            <head>
              <title>test</title>
            </head>
            <body>
              <span>服务端渲染出了真实DOM:</span>
              ${RDom}
            </body>
        </html>
        `
        //配置HTML内容对应的路由
        app.get('/index',function(req,res){
          res.send(Page)
        })
        // 配置端口号
        const server = app.listen(8000)

根据我们的路由配置,当我访问 http://localhost:8000/index 时,就可以呈现出服务端渲染的结果了:

我们可以看到,VDom 组件已经被 renderToString 转化为了一个内容为<div data-reactroot="">我是一个被渲染为真实DOM的虚拟DOM</div>的字符串,这个字符串被插入 HTML 代码,成为了真实 DOM 树的一部分。
那么 Vue 是如何实现服务端渲染的呢?
该示例直接将 Vue 实例整合进了服务端的入口文件中:

const Vue  = require('vue')
        // 创建一个express应用
        const server = require('express')()
          //提取出renderer实例
        const renderer = require('vue-server-renderer').createRenderer()
        server.get('*',(req,res)=>{
          // 编写Vue实例(虚拟DOM节点)
          const app = new Vue({
            data:{
              url:req.url
            },
            // 编写模板HTML的内容
            template:`<div>访问的URL是:{{url}}</div>`
          })
          // renderToString是把Vue实例转换为真实DOM的关键方法
          renderer.renderToString(app,(err,html)=>{
            if(err){
              res.status(500).end("Internal Server Error")
              return
            }
            // 把渲染出来的真实DOM字符串插入HTML模板中
            res.end(`
               <!DOCTYPE HTML>
                <html>
                  <head>
                    <title>hello</title>
                  </head>
                  <body>
                    ${html}
                  </body>
                </html>
            `)
          })
        })
        server.listen(8080)

实际项目比这些复杂很多,但万变不离其宗。强调的只有两点:一是这个 renderToString() 方法;二是把转化结果“塞”进模板里的这一步。这两个操作是服务端渲染的灵魂操作。在虚拟 DOM“横行”的当下,服务端渲染不再是早年 JSP 里简单粗暴的字符串拼接过程,它还要求这一端要具备将虚拟 DOM 转化为真实 DOM 的能力。与其说是“把 JS 在服务器上先跑一遍”,不如说是“把 Vue、React 等框架代码先在 Node 上跑一遍”。

服务端渲染的应用场景

服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。这样当资源抵达浏览器时,它呈现的速度就快了。乍一看好像很合理:浏览器性能毕竟有限,服务器多牛逼!能者多劳,就该让服务器多干点活!

但仔细想想,在这个网民遍地的时代,几乎有多少个用户就有多少台浏览器。用户拥有的浏览器总量多到数不清,那么一个公司的服务器又有多少台呢?我们把这么多台浏览器的渲染压力集中起来,分散给相比之下数量并不多的服务器,服务器肯定是承受不住的。服务端渲染也并非万全之策。
在实践中,建议大家先忘记服务端渲染这个事情——服务器稀少而宝贵,但首屏渲染体验和 SEO 的优化方案却很多——我们最好先把能用的低成本“大招”都用完。除非网页对性能要求太高了,以至于所有的招式都用完了,性能表现还是不尽人意,这时候我们就可以考虑向老板多申请几台服务器,把服务端渲染搞起来了~

浏览器背后的运行机制

目前市面上常见的浏览器内核可以分为这四种:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari)。
可能会听说过 Chrome 的内核就是 Webkit,殊不知 Chrome 内核早已迭代为了 Blink。但是换汤不换药,Blink 其实也是基于 Webkit 衍生而来的一个分支,因此,Webkit 内核仍然是当下浏览器世界真正的霸主。

什么是渲染过程?简单来说,渲染引擎根据 HTML 文件描述构建相应的数学模型,调用浏览器各个零部件,从而将网页资源代码转换为图像结果,这个过程就是渲染过程

我们最需要关注的,就是HTML 解释器、CSS 解释器、图层布局计算模块、视图绘制模块与JavaScript 引擎这几大模块:

  • HTML 解释器:将 HTML 文档经过词法分析输出 DOM 树。
  • CSS 解释器:解析 CSS 文档, 生成样式规则。
  • 图层布局计算模块:布局计算每个对象的精确位置和大小。
  • 视图绘制模块:进行具体节点的图像绘制,将像素渲染到屏幕上。
  • JavaScript 引擎:编译执行 Javascript 代码。
浏览器渲染过程解析

在这里插入图片描述

  • 解析 HTML

在这一步浏览器执行了所有的加载解析逻辑,在解析 HTML 的过程中发出了页面渲染所需的各种外部资源请求。

  • 计算样式

浏览器将识别并加载所有的 CSS 样式信息与 DOM 树合并,最终生成页面 render 树(:after :before 这样的伪元素会在这个环节被构建到 DOM 树中)。

  • 计算图层布局
    页面中所有元素的相对位置信息,大小等信息均在这一步得到计算。

    • 绘制图层

    在这一步中浏览器会根据我们的 DOM 代码结果,把每一个页面图层转换为像素,并对所有的媒体文件进行解码。

    • 整合图层,得到页面

    最后一步浏览器会合并合各个图层,将数据由 CPU 输出给 GPU 最终绘制在屏幕上。(复杂的视图层会给这个阶段的 GPU 计算带来一些压力,在实际应用中为了优化动画性能,我们有时会手动区分不同的图层)。
    段的 GPU 计算带来一些压力,在实际应用中为了优化动画性能,我们有时会手动区分不同的图层)。

几棵重要的“树”
在这里插入图片描述

  • DOM 树:解析 HTML 以创建的是 DOM 树(DOM tree ):渲染引擎开始解析 HTML 文档,转换树中的标签到 DOM 节点,它被称为“内容树”。
  • CSSOM 树:解析 CSS(包括外部 CSS 文件和样式元素)创建的是 CSSOM 树。CSSOM 的解析过程与 DOM 的解析过程是并行的。
  • 渲染树:CSSOM 与 DOM 结合,之后我们得到的就是渲染树(Render tree )。
  • 布局渲染树:从根节点递归调用,计算每一个元素的大小、位置等,给每个节点所应该出现在屏幕上的精确坐标,我们便得到了基于渲染树的布局渲染树(Layout of the render tree)。
  • 绘制渲染树: 遍历渲染树,每个节点将使用 UI 后端层来绘制。整个过程叫做绘制渲染树(Painting the render tree)。

渲染过程说白了,首先是基于 HTML 构建一个 DOM 树,这棵 DOM 树与 CSS 解释器解析出的 CSSOM 相结合,就有了布局渲染树。最后浏览器以布局渲染树为蓝本,去计算布局并绘制图像,我们页面的初次渲染就大功告成了。
基于渲染流程的 CSS 优化建议
CSS 引擎查找样式表,对每条规则都按从右到左的顺序去匹配。 看如下规则:

#myList  li {}

习惯了从左到右阅读的文字阅读方式,会本能地以为浏览器也是从左到右匹配 CSS 选择器的,因此会推测这个选择器并不会费多少力气:#myList 是一个 id 选择器,它对应的元素只有一个,查找起来应该很快。定位到了 myList 元素,等于是缩小了范围后再去查找它后代中的 li 元素,没毛病。

事实上,CSS 选择符是从右到左进行匹配的。我们这个看似“没毛病”的选择器,实际开销相当高:浏览器必须遍历页面上每个 li 元素,并且每次都要去确认这个 li 元素的父元素 id 是不是 myList

总结出如下性能提升的方案:

  • 避免使用通配符,只对需要用到的元素进行选择。
  • 关注可以通过继承实现的属性,避免重复匹配重复定义。
  • 少用标签选择器。如果可以,用类选择器替代,举个🌰:
  • 错误示范:
- #myList li{}

正确:

.myList_li {}
  • 减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素。

CSS 与 JS 的加载顺序优化

CSS 的阻塞

DOM 和 CSSOM 合力才能构建渲染树。这一点会给性能造成严重影响:默认情况下,CSS 是阻塞的资源。浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了,只要 CSSOM 不 OK,那么渲染这个事情就不 OK(这主要是为了避免没有 CSS 的 HTML 页面丑陋地“裸奔”在用户眼前)。
只有当我们开始解析 HTML 后、解析到 link 标签或者 style 标签时,CSS 才登场,CSSOM 的构建才开始。很多时候,DOM 不得不等待 CSSOM。

CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
  • 将 CSS 放在 head 标签里 和尽快(启用 CDN 实现静态资源加载速度的优化)
JS 的阻塞

JS 的作用在于修改,它帮助我们修改网页的方方面面:内容、样式以及它如何响应用户交互。这“方方面面”的修改,本质上都是对 DOM 和 CSSDOM 进行修改。因此 JS 的执行会阻止 CSSOM,在我们不作显式声明的情况下,它也会阻塞 DOM。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>JS阻塞测试</title>
  <style>
    #container {
      background-color: yellow;
      width: 100px;
      height: 100px;
    }
  </style>
  <script>
    // 尝试获取container元素
    var container = document.getElementById("container")
    console.log('container', container)
  </script>
</head>
<body>
  <div id="container"></div>
  <script>
    // 尝试获取container元素
    var container = document.getElementById("container")
    console.log('container', container)
    // 输出container元素此刻的背景色
    console.log('container bgColor', getComputedStyle(container).backgroundColor)
  </script>
  <style>
    #container {
      background-color: blue;
    }
  </style>
</body>
</html>

三个 console 的结果分别为:
在这里插入图片描述
第一次尝试获取 id 为 container 的 DOM 失败,这说明 JS 执行时阻塞了 DOM,后续的 DOM 无法构建;第二次才成功,这说明脚本块只能找到在它前面构建好的元素。这两者结合起来,“阻塞 DOM”得到了验证。再看第三个 console,尝试获取 CSS 样式,获取到的是在 JS 代码执行前的背景色(yellow),而非后续设定的新样式(blue),说明 CSSOM 也被阻塞了。
JS 引擎是独立于渲染引擎存在的。我们的 JS 代码在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标签时,它会暂停渲染过程,将控制权交给 JS 引擎。JS 引擎对内联的 JS 代码会直接执行,对外部 JS 文件还要先获取到脚本、再进行执行。等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建。 因此与其说是 JS 把 CSS 和 HTML 阻塞了,不如说是 JS 引擎抢走了渲染引擎的控制权。
可以通过对它使用 defer 和 async 来避免不必要的阻塞,这里我们就引出了外部 JS 的三种加载方式。

JS的三种加载方式

- 正常模式:

<script data-original="index.js"></script>

这种情况下 JS 会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情
async 模式:

<script async data-original="index.js"></script>

async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。

defer 模式:

<script defer data-original="index.js"></script>

defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。

脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer。
通过审时度势地向 script 标签添加 async/defer,我们就可以告诉浏览器在等待脚本可用期间不阻止其它的工作,这样可以显著提升性能。

DOM 优化原理与基本实践

把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接

JS 是很快的,在 JS 中修改 DOM 对象也是很快的。在JS的世界里,一切是简单的、迅速的。但 DOM 操作并非 JS 一个人的独舞,而是两个模块之间的协作。
JS 引擎和渲染引擎(浏览器内核)是独立实现的。当我们用 JS 去操作 DOM 时,本质上是 JS 引擎和渲染引擎之间进行了“跨界交流”。这个“跨界交流”的实现并不简单,它依赖了桥接接口作为“桥梁”
在这里插入图片描述

过“桥”要收费——这个开销本身就是不可忽略的。我们每操作一次 DOM(不管是为了修改还是仅仅为了访问其值),都要过一次“桥”。过“桥”的次数一多,就会产生比较明显的性能问题

对 DOM 的修改引发样式的更迭

过桥很慢,到了桥对岸,我们的更改操作带来的结果也很慢。

很多时候,我们对 DOM 的操作都不会局限于访问,而是为了修改它。当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发回流或重绘。
个过程本质上还是因为我们对 DOM 的修改触发了渲染树(Render Tree)的变化所导致的:
在这里插入图片描述
回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。

重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。

由此我们可以看出,重绘不一定导致回流,回流一定会导致重绘。硬要比较的话,回流比重绘做的事情更多,带来的开销也更大。

给你的 DOM “提提速”

减少 DOM 操作:少交“过路费”、避免过度渲染

  <!DOCTYPE html>
  <html lang="en"><head>  
  <meta charset="UTF-8">  <meta name="viewport" content="width=device-width, initial-scale=1.0">  
  <meta http-equiv="X-UA-Compatible" content="ie=edge">  
  <title>DOM操作测试</title>
  </head>
  <body>  
  <div id="container"></div>
  </body>
  </html>

此时我有一个假需求——我想往 container 元素里写 10000 句一样的话。如果我这么做:

for(var count=0;count<10000;count++){ 
  document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
} 

这段代码有两个明显的可优化点。

第一点,过路费交太多了。我们每一次循环都调用 DOM 接口重新获取了一次 container 元素,相当于每次循环都交了一次过路费。前后交了 10000 次过路费,但其中 9999 次过路费都可以用缓存变量的方式节省下来:

// 只获取一次container
let container = document.getElementById('container')
for(let count=0;count<10000;count++){ 
  container.innerHTML += '<span>我是一个小测试</span>'
} 

第二点,不必要的 DOM 更改太多了。我们的 10000 次循环里,修改了 10000 次 DOM 树。我们前面说过,对 DOM 的修改会引发渲染树的改变、进而去走一个(可能的)回流或重绘的过程,而这个过程的开销是很“贵”的。这么贵的操作,我们竟然重复执行了 N 多次!其实我们可以通过就事论事的方式节省下来不必要的渲染:

let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){ 
  // 先对内容进行操作
  content += '<span>我是一个小测试</span>'
} 
// 内容处理好了,最后再触发DOM的更改
container.innerHTML = content

JS 层面的事情,JS 自己去处理,处理好了,再来找 DOM 打报告
事实上,考虑JS 的运行速度,比 DOM 快得多这个特性。我们减少 DOM 操作的核心思路,就是让 JS 去给 DOM 分压。
这个思路,在 DOM Fragment 中体现得淋漓尽致。

DocumentFragment

DocumentFragment 接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版的 Document 使用,用于存储已排好版的或尚未打理好格式的XML片段。因为 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会引起 DOM 树的重新渲染的操作(reflow),且不会导致性能等问题
在我们上面的例子里,字符串变量 content 就扮演着一个 DOM Fragment 的角色。其实无论字符串变量也好,DOM Fragment 也罢,它们本质上都作为脱离了真实 DOM 树的容器出现,用于缓存批量化的 DOM 操作。

前面我们直接用 innerHTML 去拼接目标内容,这样做固然有用,但却不够优雅。相比之下,DOM Fragment 可以帮助我们用更加结构化的方式去达成同样的目的,从而在维持性能的同时,保住我们代码的可拓展和可维护性。我们现在用 DOM Fragment 来改写上面的例子:

let container = document.getElementById('container')
        // 创建一个DOM Fragment 对象作为容器
       let content = document.createDocumentFragment()
       for(let count = 0;count<1000;count++){
        //  span此时可以通过DOM API去创建
        let oSpan = document.createElement("span")
        oSpan.innerHTML = "我是一个小测试"
        // 像操作真实DOM一样操作DOM Fragment对象
        content.appendChild(oSpan)
       }
      //  内容处理好了  最后再触发真实的DOM的更改
      container.appendChild(content)

DOM Fragment 对象允许我们像操作真实 DOM 一样去调用各种各样的 DOM API,我们的代码质量因此得到了保证。并且它的身份也非常纯粹:当我们试图将其 append 进真实 DOM 时,它会在乖乖交出自身缓存的所有后代节点后全身而退,完美地完成一个容器的使命,而不会出现在真实的 DOM 结构中。这种结构化、干净利落的特性,使得 DOM Fragment 作为经典的性能优化手段大受欢迎,这一点在 jQuery、Vue 等优秀前端框架的源码中均有体现。

Event Loop 与异步更新策略

Event Loop 中的“渲染时机”

Micro-Task 与 Macro-Task

事件循环中的异步队列有两种:macro(宏任务)队列和 micro(微任务)队列。
常见的 macro-task 比如: setTimeout、setInterval、 setImmediate、script(整体代码)、 I/O 操作、UI 渲染等。
常见的 micro-task 比如: process.nextTick、Promise、MutationObserver 等。

Event Loop 过程解析

一个完整的 Event Loop 过程,可以概括为以下阶段:
初始状态:调用栈空。micro 队列空,macro 队列里有且只有一个 script 脚本(整体代码)。
全局上下文(script 标签)被推入调用栈,同步代码执行。在执行的过程中,通过对一些接口的调用,可以产生新的 macro-task 与 micro-task,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出 macro 队列,这个过程本质上是队列的 macro-task 的执行和出队的过程。
上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的(如下图所示)。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
在这里插入图片描述

  • 执行渲染操作,更新界面
  • 检查是否存在 Web worker 任务,如果有,则对其进行处理 。

(上述过程循环往复,直到两个队列都清空)

我们总结一下,每一次循环都是一个这样的过程:
在这里插入图片描述

渲染的时机

假如我想要在异步任务里进行DOM更新,我该把它包装成 micro 还是 macro 呢?
我们先假设它是一个 macro 任务,比如我在 script 脚本中用 setTimeout 来处理它:

// task是一个用于修改DOM的回调
setTimeout(task, 0)

现在 task 被推入的 macro 队列。但因为 script 脚本本身是一个 macro 任务,所以本次执行完 script 脚本之后,下一个步骤就要去处理 micro 队列了,再往下就去执行了一次 render,对不对?
但本次render我的目标task其实并没有执行,想要修改的DOM也没有修改,因此这一次的render其实是一次无效的render。

macro 不 ok ,我们转向 micro 试试看。我用 Promise 来把 task 包装成是一个 micro 任务:

Promise.resolve().then(task)

我们更新 DOM 的时间点,应该尽可能靠近渲染的时机。当我们需要在异步任务中实现 DOM 修改时,把它包装成 micro 任务是相对明智的选择。

异步更新策略——以 Vue 为例

什么是异步更新?

当我们使用 Vue 或 React 提供的接口去更新数据时,这个更新并不会立即生效,而是会被推入到一个队列里。待到适当的时机,队列中的更新任务会被批量触发。这就是异步更新。
异步更新可以帮助我们避免过度渲染,是我们上节提到的“让 JS 为 DOM 分压”的典范之一。

异步更新的优越性

异步更新的特性在于它只看结果,因此渲染引擎不需要为过程买单。

最典型的例子,比如有时我们会遇到这样的情况:

// 任务一
this.content = '第一次测试'
// 任务二
this.content = '第二次测试'
// 任务三
this.content = '第三次测试'

我们在三个更新任务中对同一个状态修改了三次,如果我们采取传统的同步更新策略,那么就要操作三次 DOM。但本质上需要呈现给用户的目标内容其实只是第三次的结果,也就是说只有第三次的操作是有意义的——我们白白浪费了两次计算。

但如果我们把这三个任务塞进异步更新队列里,它们会先在 JS 的层面上被批量执行完毕。当流程走到渲染这一步时,它仅仅需要针对有意义的计算结果操作一次 DOM——这就是异步更新的妙处。

Vue状态更新手法:nextTick

Vue 每次想要更新一个状态的时候,会先把它这个更新操作给包装成一个异步操作派发出去。这件事情,在源码中是由一个叫做 nextTick 的函数来完成的:

export function nextTick(cb?:Function, ctx?:Object){
    let _resolve
    callbacks.push(()=>{
        if(cb){
            try{
                cb.call(ctx)
            }catch(e){
                handleError(e,ctx,'nextTick')
            }
        }else if(_resolve){
            _resolve(ctx)
        }
    })
    // 检查上一个异步任务队列(即名为callbacks的任务数组)是否派发和执行完毕了 pending此处相当于一个锁
    if(!pending){
        // 若上一个异步任务队列已经执行完毕  则将pending设为true(把锁锁上)
        pending = true
        // 是否要求一定要派发为macro任务
        if(useMacroTask){
            macroTimerFunc()
        }else{
            // 如果不说明一定要marco  你们就全都是micro
            microTimerFunc()
        }
    }
    // $flow-disable-line
    if(!cb && typeof Promise !== 'undefined'){
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

Vue 的异步任务默认情况下都是用 Promise 来包装的,也就是是说它们都是 micro-task。这一点和我们“前置知识”中的渲染时机的分析不谋而合。
细化解析一下 macroTimeFunc() 和 microTimeFunc() 两个方法。

macroTimeFunc() 是这么实现的:

// macro首选setImmediate 这个兼容性最差
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
    isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]'
  )) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  // 兼容性最好的派发方式是setTimeout
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

microTimeFunc() 是这么实现的:

  // 简单粗暴  不是ios全都给我去Promise  如果不兼容promise  那么你只能将就一下变成marco了
        if(typeof Promise !== 'undefined'&& isNative(Promise)){
          const p = Promise.resolve()
          microTimerFunc=()=>{
            p.then(flushCallbacks)
            if(isIOS)setTimeout(noop)
          }
        }else{
          // 如果无法派发micro  就退而求次派发为macro
          microTimerFunc = macroTimerFunc
        }

我们注意到,无论是派发 macro 任务还是派发 micro 任务,派发的任务对象都是一个叫做 flushCallbacks 的东西,这个东西做了什么呢?

flushCallbacks 源码如下:

function flushCallbacks(){
          pending = false
          //callbacks在nextick中出现过  它是任务数组(队列)
          const copies = callbacks.slice(0)
          callbacks.length = 0
          //将callback中的任务逐个取出执行
          for(let i =0;i<copies.length;i++){
            copies[i]()
          }
        }

Vue 中每产生一个状态更新任务,它就会被塞进一个叫 callbacks 的数组(此处是任务队列的实现形式)中。这个任务队列在被丢进 micro 或 macro 队列之前,会先去检查当前是否有异步更新任务正在执行(即检查 pending 锁)。如果确认 pending 锁是开着的(false),就把它设置为锁上(true),然后对当前 callbacks 数组的任务进行派发(丢进 micro 或 macro 队列)和执行。设置 pending 锁的意义在于保证状态更新任务的有序进行,避免发生混乱

回流(Reflow)与重绘(Repaint)

回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。

重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。

重绘不一定导致回流,回流一定会导致重绘。硬要比较的话,回流比重绘做的事情更多,带来的开销也更大

回流的“导火索”

最“贵”的操作:改变 DOM 元素的几何属性

这个改变几乎可以说是“牵一发动全身”——当一个DOM元素的几何属性发生变化时,所有和它相关的节点(比如父子节点、兄弟节点等)的几何属性都需要进行重新计算,它会带来巨大的计算量。
常见的几何属性有 width、height、padding、margin、left、top、border 等等
“价格适中”的操作:改变 DOM 树的结构
这里主要指的是节点的增减、移动等操作。浏览器引擎布局的过程,顺序上可以类比于树的前序遍历——它是一个从上到下、从左到右的过程。通常在这个过程中,当前元素不会再影响其前面已经遍历过的元素。

最容易被忽略的操作:获取一些特定属性的值
当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,你就要注意了!
“像这样”的属性,到底是像什么样?——这些值有一个共性,就是需要通过即时计算得到。因此浏览器为了获取这些值,也会进行回流。
除此之外,当我们调用了 getComputedStyle 方法,或者 IE 里的 currentStyle 时,也会触发回流。原理是一样的,都为求一个“即时性”和“准确性”。

如何规避回流与重绘

将“导火索”缓存起来,避免频繁改动

有时我们想要通过多次计算得到一个元素的布局位置,我们可能会这样做:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<style>
    #el{
        width:100%;
        height:100%;
        background-color: yellow;
        position:absolute;
    }
</style>
<body>
    <div id="el"></div>
    <script>
        // 获取el元素
        const el = document.getElementById('el')
        // 这里循环判定比较简单实际中获取会拓展出比较复杂的判定需求
        for(let i =0;i<10;i++){
            el.style.top = el.offsetHeight + 10 + 'px'
            el.style.left = el.offsetLeft + 10 + 'px'
        }
    </script>
</body>
</html>

这样做,每次循环都需要获取多次“敏感属性”,是比较糟糕的。我们可以将其以 JS 变量的形式缓存起来,待计算完毕再提交给浏览器发出重计算请求:

//    缓存offsetLeft与offsetTop的值
    const el = document.getElementById('el')
    let offLeft = el.offsetLeft,offTop = el.offsetTop
    // 在js层面进行计算
    for(let i =0;i<10;i++){
        offsetLeft += 10
        offsetTop += 10
    }
    // 一次性将计算结果应用到DOM上
    el.style.left = offLeft + 'px'
    el.style.top = offTop + 'px'

避免逐条改变样式,使用类名去合并样式

比如我们可以把这段单纯的代码

const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

优化成一个有 class 加持的样子:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<style>
   .basic_style {
       width: 100px;
       height: 200px;
       border: 10px solid red;
       color:red;
   }
</style>
<body>
    <div id="container"></div>
    <script>
      let container = document.getElementById('container')
      container.classList.add('basic_style')
    </script>
</body>
</html>

前者每次单独操作,都去触发一次渲染树更改,从而导致相应的回流与重绘过程。

合并之后,等于我们将所有的更改一次性发出,用一个 style 请求解决掉了。

将 DOM “离线”

所说的回流和重绘,都是在“该元素位于页面上”的前提下会发生的。一旦我们给元素设置 display: none,将其从页面上“拿掉”,那么我们的后续操作,将无法触发回流与重绘——这个将元素“拿掉”的操作,就叫做 DOM 离线化。

仍以这段代码片段为例:

const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

离线化后就是这样:

const container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
.....
container.style.display = 'block'

把它拿下来了,后续不管我操作这个元素多少次,每一步的操作成本都会非常低。当我们只需要进行很少的 DOM 操作时,DOM 离线化的优越性确实不太明显。一旦操作频繁起来,这“拿掉”和“放回”的开销都将会是非常值得的。

Flush 队列:浏览器并没有那么简单

let container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

这段代码里,浏览器进行了多少次的回流或重绘呢
“width、height、border是几何属性,各触发一次回流;color只造成外观的变化,会触发一次重绘。”
因为现代浏览器是很聪明的。浏览器自己也清楚,如果每次 DOM 操作都即时地反馈一次回流或重绘,那么性能上来说是扛不住的。于是它自己缓存了一个 flush 队列,把我们触发的回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些任务一口气出队。因此我们看到,上面就算我们进行了 4 次 DOM 更改,也只触发了一次 Layout 和一次 Paint。

提到过有一类属性很特别,它们有很强的“即时性”。当我们访问这些属性时,浏览器会为了获得此时此刻的、最准确的属性值,而提前将 flush 队列的任务出队——这就是所谓的“不得已”时刻。

并不是所有的浏览器都是聪明的。Chrome 里行得通的东西,到了别处(比如 IE)就不一定行得通了。而我们并不知道用户会使用什么样的浏览器。如果不手动做优化,那么一个页面在不同的环境下就会呈现不同的性能效果,这对我们、对用户都是不利的。因此,养成良好的编码习惯、从根源上解决问题,仍然是最周全的方法。

优化首屏体验——Lazy-Load

Lazy-Load,翻译过来是“懒加载”。它是针对图片加载时机的优化:在一些图片量比较大的网站,
,如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象,因为图片真的太多了,一口气处理这么多任务,浏览器做不到啊!

只要我们可以在页面打开的时候把首屏的图片资源加载出来,用户就会认为页面是没问题的。至于下面的图片,我们完全可以等用户下拉的瞬间再即时去请求、即时呈现给他。这样一来,性能的压力小了,用户的体验却没有变差——这个延迟加载的过程,就是 Lazy-Load。

写一个 Lazy-Load

Lazy-Load 的思路及实现方式为大厂面试常考题
我们在 index.html 中,为这些图片预置 img 标签:

<!--
 * @Author: yang
 * @Date: 2020-11-29 15:02:59
 * @LastEditors: yang
 * @LastEditTime: 2020-11-29 15:28:43
 * @FilePath: \gloud-h5-demo\src\component\index\index.html
-->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .img{
            width: 200px;
            height: 200px;
            background-color: gray;
        }
    </style>
</head>
<body>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/1.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/2.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/3.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/4.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/5.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/6.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/7.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/8.png">
    </div>
    <div class="img">
        <img class="pic" alt="加载中" data-data-original="./images/9.png">
    </div>
</body>
</html>

在懒加载的实现中,有两个关键的数值:一个是当前可视区域的高度,另一个是元素距离可视区域顶部的高度。
当前可视区域的高度, 在和现代浏览器及 IE9 以上的浏览器中,可以用 window.innerHeight 属性获取。在低版本 IE 的标准模式中,可以用 document.documentElement.clientHeight 获取,这里我们兼容两种情况:

const viewHeight = window.innerHeight||document.documentElement.clientHeight 

而元素距离可视区域顶部的高度,我们这里选用 getBoundingClientRect() 方法来获取返回元素的大小及其相对于视口的位置
(DOMRect 对象包含了一组用于描述边框的只读属性——left、top、right 和 bottom,单位为像素。除了 width 和 height 外的属性都是相对于视口的左上角位置而言的。)

可以看出,top 属性代表了元素距离可视区域顶部的高度,正好可以为我们所用

Lazy-Load 方法开工

        // 获取所有的图片标签
        const imgs = document.getElementsByTagName('img')
        // 获取可视区域的高度
        const viewHeight = window.innerHeight || document.documentElement.clientHeight
        // num用于统计当前显示到了哪一张图片  避免每次都从第一张图片开始检查是否漏出
        let num = 0
        function lazyload(){
            for(let i = num;i<imgs.length;i++){
                // 用可视区域高度减去元素顶部据可视区域顶部的高度
            let distance = viewHeight - imgs[i].getBoundingClientRect().top
            // 如果可视区域高度大于元素顶部距离可视区域顶部的高度,说明元素露出
            if(distance>=0){
                //给元素写入真实的src  展示图片
                imgs[i].src = imgs[i].getAttribute('data-src')
                // 前i张图片已经加载完毕  下次从i+1张开始检查是否露出
                num = i+1
            }
                
            }
        }
        // 监听Scroll事件
        window.addEventListener('scroll',lazyload,false)

这个 scroll 事件,是一个危险的事件——它太容易被触发了。试想,用户在访问网页的时候,是不是可以无限次地去触发滚动?尤其是一个页面死活加载不出来的时候,疯狂调戏鼠标滚轮(或者浏览器滚动条)的用户可不在少数啊!

再回头看看我们上面写的代码。按照我们的逻辑,用户的每一次滚动都将触发我们的监听函数。函数执行是吃性能的,频繁地响应某个事件将造成大量不必要的页面计算。因此,我们需要针对那些有可能被频繁触发的事件作进一步地优化。

事件的节流(throttle)与防抖(debounce)

scroll 事件是一个非常容易被反复触发的事件。其实不止 scroll 事件,resize 事件、鼠标事件(比如 mousemove、mouseover 等)、键盘事件(keyup、keydown 等)都存在被频繁触发的风险。

频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。为了规避这种情况,我们需要一些手段来控制事件被触发的频率。就是在这样的背景下,throttle(事件节流)和 debounce(事件防抖)出现了。

“节流”与“防抖”的本质

这两个东西都以闭包的形式存在。

它们通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率。

Throttle: 第一个人说了算

throttle 的中心思想在于:在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应。
所谓的“节流”,是通过在一段时间内无视后来产生的回调请求来实现的。
每当用户触发了一次 scroll 事件,我们就为这个触发操作开启计时器。一段时间内,后续所有的 scroll 事件都会被当作“一辆车的乘客”——它们无法触发新的 scroll 回调。直到“一段时间”到了,第一次触发的 scroll 事件对应的回调才会执行,而“一段时间内”触发的后续的 scroll 回调都会被节流阀无视掉。

  //  fn是我们需要包装的事件回调  interval是时间间隔的阈值
        function throttle(fn,interval){
            // last为上一次触发回调的时间、
            let last = 0
            // 将throwttle处理结果当做函数返回
            return function(){
                // 保留调用时的this上下文
                let context = this
                // 保留调用时传入的参数
                let args = arguments
                //记录本次触发回调的时间
                let now = + new Date()
                //判断上次触发的时间和本次触发的时间差是否小于时间间隔的阀值
                if(now - last >= interval){
                    // 如果时间间隔大于我们设定的时间间隔阀值  则执行回调
                    last = now
                    fn.apply(context,args)
                }
            }
        }
        // 用throwttle来包装scroll的回调
        document.addEventListener('scroll',throttle(()=>console.log('触发了滚动事件'),1000))

Debounce: 最后一个人说了算

防抖的中心思想在于:我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。

 //   fn是需要包装的事件回调  delay是每次推迟执行的等待时间
    function debounce(fn,delay){
        //定时器
        let timer = null
        // 将debounce处理结果当做函数返回
        return function(){
            //保留调用时的this上下文
            let context = this
            // 保留调用时传入的参数
            let args = arguments
            //每次事件被触发时  都去清除之前的旧定时器
            if(timer){
                clearTimeout(timer)
            }
            // 设立新定时器
            timer = setTimeout(function(){
                fn.apply(context,args)
            },delay)
        }
    }
    document.addEventListener('scroll',debounce(()=>console.log('触发了滚动事件'),1000))

用 Throttle 来优化 Debounce

debounce 的问题在于它“太有耐心了”。试想,如果用户的操作十分频繁——他每次都不等 debounce 设置的 delay 时间结束就进行下一次操作,于是每次 debounce 都为该用户重新生成定时器,回调函数被延迟了不计其数次。频繁的延迟会导致用户迟迟得不到响应,用户同样会产生“这个页面卡死了”的观感。

为了避免弄巧成拙,我们需要借力 throttle 的思想,打造一个“有底线”的 debounce——等你可以,但我有我的原则:delay 时间内,我可以为你重新生成定时器;但只要delay的时间到了,我必须要给用户一个响应。这个 throttle 与 debounce “合体”思路,已经被很多成熟的前端库应用到了它们的加强版 throttle 函数的实现中:

 //  fn是我们需要包装的事件回调, delay是时间间隔的阀值
    function throttle(fn,delay){
        // last 为上次触发回调的事件 timer是定时器
        let last = 0,timer = null;
        // 将throttle处理结果当做函数返回
        return function(){
            // 保留调用时的this上下文
            let context = this
            // 保留调用时传入的参数
            let args = arguments
            // 记录本次触发回调的时间
            let now = +new Date()
            // 判断上次触发时间和本地触发的时间差是否小于时间间隔的阀值
            if(now-last>delay){
                // 如果时间间隔小于我们设定的时间间隔阀值  则为本次触发操作设立一个新的定时器
                clearTimeout(timer)
                timer = setTimeout(function(){
                    last = now
                    fn.apply(contxt,args)
                },delay)
            }else{
                // 如果时间间隔超出了我们设定的时间间隔阀值  那就不等了  无论如何要反馈给用户一次响应
                last = now
                fn.apply(context,args)
            }
        }
    }
    // 用新的throttle包装scroll的回调
    document.addEventListener('scroll',throttle(()=>console.log('触发了滚动事件'),1000))

性能监测

可视化监测:从 Performance 面板说起

Performance是Chrome
提供给我们的开发者工具,用于记录和分析我们的应用在运行时的所有活动。它呈现的数据具有实时性、多维度的特点,可以帮助我们很好地定位性能问题。
开始记录
右键打开开发者工具,选中我们的
Performance
面板:
在这里插入图片描述
当我们选中图中所标示的实心圆按钮,Performance

会开始帮我们记录我们后续的交互操作;当我们选中圆箭头按钮,Performance
会将页面重新加载,计算加载过程中的性能表现。
tips:使用
Performance
工具时,为了规避其它
Chrome
插件对页面的性能影响,我们最好在无痕模式下打开页面

挖掘性能瓶颈

看 Main 栏目下的火焰图和 Summary 提供给我们的饼图——这两者和概述面板中的 CPU 一栏结合,可以帮我们迅速定位性能瓶颈
在这里插入图片描述
从上到下,依次为概述面板、详情面板
观察一下概述面板
FPS:这是一个和动画性能密切相关的指标,它表示每一秒的帧数。图中绿色柱状越高表示帧率越高,体验就越流畅。若出现红色块,则代表长时间帧,很可能会出现卡顿。图中以绿色为主,偶尔出现红块,说明网页性能并不糟糕,但仍有可优化的空间。

CPU:表示CPU的使用情况,不同的颜色片段代表着消耗CPU资源的不同事件类型。这部分的图像和下文详情面板中的Summary内容有对应关系,我们可以结合这两者挖掘性能瓶颈。

NET:粗略的展示了各请求的耗时与前后顺序。这个指标一般来说帮助不大。
先看 CPU 图表和 Summary 饼图。CPU 图表中,我们可以根据颜色填充的饱满程度,确定 CPU 的忙闲,进而了解该页面的总的任务量。而 Summary 饼图则以一种直观的方式告诉了我们,哪个类型的任务最耗时(从本例来看是脚本执行过程)。这样我们在优化的时候,就可以抓到“主要矛盾”,进而有的放矢地开展后续的工作了。

再看 Main 提供给我们的火焰图。这个火焰图非常关键,它展示了整个运行时主进程所做的每一件事情(包括加载、脚本运行、渲染、布局、绘制等)。x 轴表示随时间的记录。每个长条就代表一个活动。更宽的条形意味着事件需要更长时间。y 轴表示调用堆栈,我们可以看到事件是相互堆叠的,上层的事件触发了下层的事件。

CPU 图标和 Summary 图都是按照“类型”给我们提供性能信息,而 Main 火焰图则将粒度细化到了每一个函数的调用。到底是从哪个过程开始出问题、是哪个函数拖了后腿、又是哪个事件触发了这个函数,这些具体的、细致的问题都将在 Main 火焰图中得到解答。

可视化监测: 更加聪明的 LightHouse

Performance 无疑可以为我们提供很多有价值的信息,但它的展示作用大于分析作用。它要求使用者对工具本身及其所展示的信息有充分的理解,能够将晦涩的数据“翻译”成具体的性能问题。

程序员们许了个愿:如果工具能帮助我们把页面的问题也分析出来就好了!上帝听到了这个愿望,于是给了我们 LightHouse:
Lighthouse 是一个开源的自动化工具,用于改进网络应用的质量。 你可以将其作为一个 Chrome 扩展程序运行,或从命令行运行。 为Lighthouse 提供一个需要审查的网址,它将针对此页面运行一连串的测试,然后生成一个有关页面性能的报告。
首先在 Chrome 的应用商店里下载一个 LightHouse。这一步 OK 之后,我们浏览器右上角会出现一个小小的灯塔 ICON。打开我们需要测试的那个页面,点击这个 ICON,唤起如下的面板:
在这里插入图片描述
然后点击“Generate report”按钮,只需静候数秒,LightHouse 就会为我们输出一个完美的性能报告。

这里我拿掘金小册首页“开刀”:
稍事片刻,Report 便输出成功了,LightHouse 默认会帮我们打开一个新的标签页来展示报告内容。报告内容非常丰富,首先我们看到的是整体的跑分情况:
在这里插入图片描述
上述分别是页面性能、PWA(渐进式 Web 应用)、可访问性(无障碍)、最佳实践、SEO 五项指标的跑分。孰强孰弱,我们一看便知。

向下拉动 Report 页,我们还可以看到每一个指标的细化评估:
在这里插入图片描述
在“Opportunities”中,LightHouse 甚至针对我们的性能问题给出了可行的建议、以及每一项优化操作预期会帮我们节省的时间。这份报告的可操作性是很强的——我们只需要对着 LightHouse 给出的建议,一条一条地去尝试,就可以看到自己的页面,在一秒一秒地变快。

除了直接下载,我们还可以通过命令行使用 LightHouse:

   npm install -g lighthouse
    lighthouse https://juejin.im/books

同样可以得到掘金小册的性能报告。

此外,从 Chrome 60 开始,DevTools 中直接加入了基于 LightHouse 的 Audits 面板:
在这里插入图片描述

可编程的性能上报方案: W3C 性能 API Performance

W3C 规范为我们提供了 Performance 相关的接口。它允许我们获取到用户访问一个页面的每个阶段的精确时间,从而对性能进行分析。我们可以将其理解为 Performance 面板的进一步细化与可编程化。

当下的前端世界里,数据可视化的概念已经被炒得非常热了,Performance 面板就是数据可视化的典范。那么为什么要把已经可视化的数据再掏出来处理一遍呢?这是因为,需要这些数据的人不止我们前端——很多情况下,后端也需要我们提供性能信息的上报。此外,Performance 提供的可视化结果并不一定能够满足我们实际的业务需求,只有拿到了真实的数据,我们才可以对它进行二次处理,去做一个更加深层次的可视化。

在这种需求背景下,我们就不得不祭出 Performance API了。
访问 performance 对象

performance 是一个全局对象。我们在控制台里输入 window.performance,就可一窥其全貌:

在这里插入图片描述
关键时间节点

在 performance 的 timing 属性中,我们可以查看到如下的时间戳:
在这里插入图片描述
这些时间戳与页面整个加载流程中的关键时间节点有着一一对应的关系
在这里插入图片描述
通过求两个时间点之间的差值,我们可以得出某个过程花费的时间,举个🌰:

const timing = window.performance.timing
// DNS查询耗时
timing.domainLookupEnd - timing.domainLookupStart
  
// TCP连接耗时
timing.connectEnd - timing.connectStart
 
// 内容加载耗时
timing.responseEnd - timing.requestStart

···

除了这些常见的耗时情况,我们更应该去关注一些关键性能指标:firstbyte、fpt、tti、ready 和 load 时间。这些指标数据与真实的用户体验息息相关,是我们日常业务性能监测中不可或缺的一部分:

// firstbyte:首包时间    
timing.responseStart – timing.domainLookupStart    

// fpt:First Paint Time, 首次渲染时间 / 白屏时间
timing.responseEnd – timing.fetchStart

// tti:Time to Interact,首次可交互时间    
timing.domInteractive – timing.fetchStart

// ready:HTML 加载完成时间,即 DOM 就位的时间
timing.domContentLoaded – timing.fetchStart

// load:页面完全加载时间
timing.loadEventStart – timing.fetchStart

以上这些通过 Performance API 获取到的时间信息都具有较高的准确度。我们可以对此进行一番格式处理之后上报给服务端,也可以基于此去制作相应的统计图表,从而实现更加精准、更加个性化的性能耗时统计。

此外,通过访问 performance 的 memory 属性,我们还可以获取到内存占用相关的数据;通过对 performance 的其它属性方法的灵活运用,我们还可以把它耦合进业务里,实现更加多样化的性能监测需求——灵活,是可编程化方案最大的优点。

查看原文

赞 0 收藏 0 评论 0

yang 发布了文章 · 2020-11-16

canvas笔记

canvas代码

基本使用

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        #canvas {
            background: #000;
        }
    </style>
</head>
<body>
<canvas id="canvas" width="400" height="400">

</canvas>
<script>
    var canvas = document.getElementById("canvas");
    var context = canvas.getContext("2d");

    context.beginPath();
    context.arc(100, 100, 50, 0, Math.PI * 2, true);
    context.closePath();
    context.fillStyle = 'rgb(255,255,255)';
    context.fill();
</script>
</body>
</html>
也可使用js设置canvas的宽高,以及所代表的的意思
<template>
 <canvas id="canvas"  ref="canvas"></canvas>
</template>

<script>
  export default {
    methods: {
      init() {
        let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')//获取到 Canvas 的上下文环境 代表一个二维渲染上下文
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() // 起始一条路径,或重置当前路径
        context.arc(100,100,50,0,Math.PI*2,true)// 创建弧/曲线
        context.closePath()// 创建从当前点回到起始点的路径
        context.fillStyle = 'rgb(255,255,255)' // 设置或返回用于填充绘画的颜色、渐变或模式
        context.fill()// 填充当前绘图(路径)
      }
    },
    mounted () {
      this.init()
    },
  }
</script>

<style lang="scss" scoped>
#canvas{
  background-color: #000;
}
</style>

注意
*不要使用 CSS 设置。因为默认创建一个 300 150 的画布,
如果使用 CSS 来设置宽高的话,画布就会按照 300 150 的比例进行缩放,也就是将 300 150 的页面显示在 400 400 的容器中*

绘制路径

image.png

使用 Canvas 绘制图像的步骤
image.png

绘制弧/曲线

arc() 方法创建弧/曲线(用于创建圆或部分圆)。

context.arc(x,y,r,sAngle,eAngle,counterclockwise);

  • x:圆心的 x 坐标
  • y:圆心的 y 坐标
  • r:圆的半径
  • sAngle:起始角,以弧度计(弧的圆形的三点钟位置是 0 度)
  • eAngle:结束角,以弧度计
  • counterclockwise:可选。规定应该逆时针还是顺时针绘图。false 为顺时针,true 为逆时针

image.png

画一个顺时针的四分之一圆

let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() 
        context.arc(100,100,50,0,Math.PI*0.5,false)
        context.strokeStyle = "white"
        context.stroke()

因为我们设置的起始角是 0,对照 w3cschool 上的截图可知弧度的 0 的位置是 3 点钟方向,然后结束角我们设置为 0.5 PI,也就是 6 点钟方向

stroke()和fill()的区别

  • stroke() :描边
  • fill() :填充

我们可以通过 strokeStyle属性 和 fillStyle属性来设置描边和填充的颜色

绘制直线

let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() 
        context.moveTo(50,50)
        context.lineTo(100,100)
        context.strokeStyle = "white"
        context.stroke()
  • moveTo(x,y):把路径移动到画布中的指定点,不创建线条
  • lineTo(x,y):添加一个新点,然后在画布中创建从该点到最后指定点的线条

这里需要注意以下几点:

  • 如果没有 moveTo,那么第一次 lineTo 的就视为 moveTo
  • 每次 lineTo 后如果没有 moveTo,那么下次 lineTo 的开始点为前一次 lineTo 的结束点。

也就是这种情况:

 let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() 
        context.lineTo(100,100)
        context.lineTo(50,100)
        context.lineTo(200,200)
        context.lineTo(20,10)
        context.strokeStyle = "white"
        context.stroke()

我们没有设置 moveTo,而是设置了三个 lineTo,这也是可以的,将三个 lineTo 设置的点依次连接就好~

给绘制的直线添加样式

image.png
其宽度设置为 10,并且加上“圆角”的效果

let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() 
        context.moveTo(50,100)
        context.lineTo(200,200)
        context.lineWidth = 10
        context.lineCap = 'round'
        context.strokeStyle = "white"
        context.stroke()

image.png

绘制矩形

 let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() 
        context.fillStyle = '#fff'
        context.fillRect(10,10,100,100)
        context.strokeStyle = '#fff'
        context.strokeRect(130,10,100,100)

image.png

  • fillRect(x,y,width,height):绘制一个实心矩形
  • strokeRect(x,y,width,height):绘制一个空心矩形

颜色、样式和阴影

image.png

  let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        context.beginPath() 
        context.arc(100,100,50,0,2*Math.PI,false);
        context.fillStyle = '#fff';
        context.shadowBlur = 20
        context.shadowColor = "#fff"
        context.fill()

image.png

设置渐变

image.png
绘制渐变主要用到了 createLinearGradient() 方法,我们来看一下这个方法:context.createLinearGradient(x0,y0,x1,y1);

  • x0:开始渐变的 x 坐标
  • y0:开始渐变的 y 坐标
  • x1:结束渐变的 x 坐标
  • y1:结束渐变的 y 坐标
let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        var grd = context.createLinearGradient(100,100,100,200)
        grd.addColorStop(0,'pink');
        grd.addColorStop(1,'lightBlue')
        context.fillStyle = grd;
        context.fillRect(100,100,200,200)

image.png
createLinearGradient() 的参数是两个点的坐标,这两个点的连线实际上就是渐变的方向。我们可以使用 addColorStop() 方法来设置渐变的颜色。

gradient.addColorStop(stop,color);:

  • stop:介于 0.0 与 1.0 之间的值,表示渐变中开始与结束之间的位置
  • color:在结束位置显示的 CSS 颜色值
 let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
        var grd = context.createLinearGradient(0,0,0,400)
        grd.addColorStop(0,'pink');
        grd.addColorStop(0.2,'lightBlue')
        grd.addColorStop(0.4,'red')
        grd.addColorStop(0.6,'pink')
        grd.addColorStop(0.8,'black')
        grd.addColorStop(1,'yellow')
        context.fillStyle = grd;
        context.fillRect(0,0,400,400)

image.png

图形转换

image.png

let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
       context.strokeStyle = 'white'
       context.strokeRect(5,5,50,25)
       context.scale(2,2)
       context.strokeRect(5,5,50,25)
       context.scale(2,2)
       context.strokeRect(5,5,50,25)

image.png

旋转

let canvas = this.$refs.canvas
        let context = canvas.getContext('2d')
        let cx = canvas.width = 400
        let cy = canvas.height = 400
       context.fillStyle = 'white'
       context.rotate(20*Math.PI/180)
       context.fillRect(70,30,200,100)

image.png

context.rotate(angle);

  • angle : 旋转角度,以弧度计。如需将角度转换为弧度,请使用 degrees*Math.PI/180 公式进行计算。举例:如需旋转 5 度,可规定下面的公式:5*Math.PI/180
  • 、我们将画布旋转了 20°,然后再画了一个矩形。

在进行图形变换的时候,我们需要画布旋转,然后再绘制图形,
使用的图形变换的方法都是作用在画布上的,既然对画布进行了变换,那么在接下来绘制的图形都会变换。
比如我对画布使用了 rotate(20*Math.PI/180) 方法,就是将画布旋转了 20°,然后之后绘制的图形都会旋转 20°。

图像绘制

Canvas 还有一个经常用的方法是drawImage()
image.png
context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);

  • img:规定要使用的图像、画布或视频
  • sx:可选。开始剪切的 x 坐标位置
  • sy:可选。开始剪切的 y 坐标位置
  • swidth:可选。被剪切图像的宽度
  • sheight:可选。被剪切图像的高度
  • x:在画布上放置图像的 x 坐标位置
  • y:在画布上放置图像的 y 坐标位置
  • width:可选。要使用的图像的宽度(伸展或缩小图像)
  • height:可选。要使用的图像的高度(伸展或缩小图像)

炫酷背景特效的通性

  • 背景

    • 单一颜色
    • 渐变
    • 平铺
  • 炫酷

    • 随机
  • 特效(与用户交互)

    • 鼠标跟随
    • 视觉差
背景

景往往是纯色的或者是渐变的,再或者就是有规律的可以平铺的图形。
封面图一般都是一个渐变的背景 + 文字。简洁却不简单
过渡色获取:uigradients
这个网站可以自己生成渐变色,你的配色也可以跟大家分享,可以保存为图片,也可以导出为 CSS 样式。

我们可以从这个网站上找到喜欢的配色,然后导出为 CSS 样式使用。
image.png
星空背景的渐变实际上不是使用 Canvas 写的,只是使用 CSS 写出的效果。实现的方式是:

下面的树是一个 png 的背景
image.png
然后我们将 body 的颜色设置为黑色到蓝色的由上向下的渐变:

background:linear-gradient(to bottom,#000000 0%,#5788fe 100%)

接下来我们要设置一个全屏的遮罩,将这个遮罩的背景色设置为红色,然后使用 CSS3 的 animation 属性,使用 animation 改变其透明度,由 0 变为 0.9。

.filter{
    width:100%;
    height:100%;
    position:absolute;
    top:0;
    left:0;
    background:#fe5757;
    animation:colorChange 30s ease-in-out infinite;
    animation-fill-mode:both;
    mix-blend-mode:overlay;
}
@keyframs colorChange{
    0%,100%{
     opacity:0;
    }
    50%{
    opacity:.9;
    }
}

效果就和上面动态的效果一样。

炫酷
  • 随机

让元素动起来

*   gif 图
    
*   CSS3 动画
    
*   js 控制
    
*   svg
    
*   Canvas

随机
使用 gif 图大家都知道,只能是有规律的“动”,并且 gif 图片的尺寸不宜过大,在我们的网页背景中,基本上是不会用到的。

CSS3 实现的动画效果,也是只能做有规律的“动”,并且 CSS 只能操纵单个的 DOM 元素,一旦元素到达一定的数量,代价是比较大的。

所以我们选择 js + Canvas 来实现“随机”的“动”。

效果

主要是与鼠标之间的交互效果。

与鼠标之间有互动的效果主要是产生用户行为的反馈,比如在网页制作中,我们经常使用 hover 变色表示用户的鼠标在元素上方悬停。这就是用户行为的一种反馈。

我们经常使用的与鼠标之间的交互效果主要有两种:

  • 鼠标跟随
  • 视觉差
    用户很喜欢这种鼠标跟随的效果,个人觉得就是因为它使得网站的显示效果和用户的行为产生了联系,使用户的行为得到了反馈。

还有一种经常见到的效果是数据差的效果,比如:

视觉差效果

这是锤子官网的一个特效,鼠标移动到哪哪就会下沉,并且如果你仔细看的话就会发现,上面的月份数字和底部的图片不是在一个层级上的,更加有立体的感觉,这就是视觉差的特效。

这种特效不需要用 Canvas,只需要 CSS 就可以实现

怎么实现随机粒子

如果只是一个纯色或者渐变的背景,肯定会显得有点单调,我们还需要在渐变的基础上加一点 “料”,而这些 “料”通常都是粒子特效。 那么“粒子特效” 都有什么特点呢?

  • 粒子
  • 规则图形
  • 随机
  • 数量多

将无数的单个粒子组合使其呈现出固定形态,借由控制器,脚本来控制其整体或单个的运动,模拟出现真实的效果。

粒子特效的首要特点是数量多,在物理学中,粒子是能够以自由状态存在的最小物质组成部分,所以粒子的第一个特点就是数量多。

粒子特效的第二个特点是运动,正是因为组成物体的粒子在不断运动,我们才能看到物体在不断运动。

粒子特效第三个特点是随机,排列有整齐排列之美,凌乱有凌乱之美,整齐排列的可以直接平铺背景实现,直接使用 img 图片就可以。

但是要想有随机效果使用 img 图片就不可以了,所以我们主要使用 Canvas 实现随机粒子效果。各项参数都是随机生成的。
现在我们来一起实现一个随机粒子特效。

效果如下:

image.png

创建全屏 Canvas

首先,我们需要一个全屏的 Canvas 画布

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        html,
        body {
            margin: 0;
            overflow: hidden;
            width: 100%;
            height: 100%;
            background-color: #000;
        }
    </style>

</head>

<body>
    <canvas id="canvas"></canvas>
</body>
<script>
    var ctx = document.getElementById('canvas'),
        content = ctx.getContext('2d'),
        WIDTH, HEIGHT;
    WIDTH = document.documentElement.clientWidth;
    HEIGHT = document.documentElement.clientHeight;
    initRoundPopulation = 80,
    WIDTH, HEIGHT;
   
</script>

</html>

我们使用 WIDTHHEIGHT 两个常量储存屏幕宽度和高度信息,我们习惯使用大写来表示改变量为常量,不可变,将屏幕宽度和高度信息储存在常量中是因为我们在稍后还会用到。

这时,你应该得到一个全屏的并且为黑色的 Canvas。

设置 Round_item

创建单个的 Round_item 类。
要设置的是位置随机、透明度随机、半径随机的圆。为了区分不同的圆,我们还应该设置一个唯一的 index 参数。

所以我们需要的参数有:

  • x 坐标
  • y 坐标
  • 半径
  • 透明度
  • index

根据上面这些可以得出我们的 Round_item 类:

 function Round_item(index,x,y){
        this.index = index
        this.x = x;
        this.y = y;
        this.r = Math.random()*2+1; //随机半径
        var alpha = (Math.floor(Math.random()*10)+1)/10/2
        this.color = "rgba(255,255,255,"+alpha+")"
    }

这里我们使用了构造函数的方式来创建单个的圆,我们还需要一个变量 initRoundPopulation 来设置 round 的个数,然后我们便可以通过 for 循环创建出 initRoundPopulation 个圆。

设置 draw() 方法

在设置了单个的 Round_item 类之后,我们还要给每一个 round 设置 draw() 方法,所以我们需要将 draw() 方法设置在 Round_item 的原型中,这样我们创建出来的每一个 Round_item 实例对象都拥有了 draw() 方法。

 Round_item.prototype.draw = function(){
        content.fillStyle = this.color;
        // shadowBlur阴影的模糊级别
        content.shadowBlur = this.r*2;
        content.beginPath()
        // x 坐标 y坐标 半径 起始角 结束角   顺时针逆时针
        content.arc(this.x,this.y,this.r,0,2*Math.PI,false)
        content.closePath()
        content.fill()
    }

设置初始化 init() 函数

然后我们就需要设置初始化 init() 函数了,在 init() 函数中,我们的主要任务是创建单个的 round,然后使用其 draw() 方法。

function init(){
        for (var i = 0; i < initRoundPopulation; i++) {
            round[i] = new Round_item(i,Math.random()*WIDTH,Math.random().HEIGHT)
            round[i].draw()
        }
    }

至此,我们已经完成了随机粒子的实现,完整的代码如下:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        html,
        body {
            margin: 0;
            overflow: hidden;
            width: 100%;
            height: 100%;
            background-color: #000;
        }
    </style>

</head>

<body>
    <canvas id="canvas"></canvas>
</body>
<script>
    var ctx = document.getElementById('canvas'),
        content = ctx.getContext('2d'),
        initRoundPopulation = 80,
        round = [],
        WIDTH, HEIGHT;
    WIDTH = document.documentElement.clientWidth;
    HEIGHT = document.documentElement.clientHeight;
    ctx.width = WIDTH;
    ctx.height = HEIGHT;
    function Round_item(index,x,y){
        this.index = index
        this.x = x;
        this.y = y;
        this.r = Math.random()*2+1; //随机半径
        var alpha = (Math.floor(Math.random()*10)+1)/10/2
        this.color = "rgba(255,255,255,"+alpha+")"
    }
    Round_item.prototype.draw = function(){
        content.fillStyle = this.color;
        // shadowBlur阴影的模糊级别
        content.shadowBlur = this.r*2;
        content.beginPath()
        // x 坐标 y坐标 半径 起始角 结束角   顺时针逆时针
        content.arc(this.x,this.y,this.r,0,2*Math.PI,false)
        content.closePath()
        content.fill()
    }

    function init(){
        for (var i = 0; i < initRoundPopulation; i++) {
            round[i] = new Round_item(i,Math.random()*WIDTH,Math.random().HEIGHT)
            round[i].draw()
        }
    }
    init()
</script>

</html>

使你的随机粒子动起来

animate() 函数

Canvas 制作动画是一个不断擦除再重绘的过程,跟最原始实现动画的方式类似。在纸片上画每一帧,然后以很快的速度翻动小本本,就会有动画的效果。
现在我们实现动画需要在很短的时间内不断的清除内容再重新绘制,新的图形和原先清除的图形之间有某种位置关系,速度足够快的话,我们就会看到动画的效果。
所以我们需要一个 animate() 函数,这个函数的作用是帮助我们形成动画,我们在这个函数中首先需要清除当前屏幕,这里的清除函数用到的是 content.clearRect() 方法。

canvas 的 content.clearRect() 方法:

context.clearRect(x,y,width,height);

  • x:要清除的矩形左上角的 x 坐标
  • y:要清除的矩形左上角的 y 坐标
  • width:要清除的矩形的宽度,以像素计
  • height:要清除的矩形的高度,以像素计

我们需要清除的区域是整个屏幕,所以 content.clearRect() 的参数就是 content.clearRect(0, 0, WIDTH, HEIGHT);,这里我们就用到了之前获取的屏幕宽度和高度的常量:WIDTHHEIGHT

粒子匀速上升。粒子匀速上升,也就是 y 坐标在不断地变化,既然是匀速的,那么也就是在相同的时间位移是相同的。
重新绘制完图形之后,我们就完成了清除屏幕内容再重新绘制新的图形的任务。那么还需要有一个步骤 —— “

不断”,要想实现动画的效果,就需要 “不断” 地进行清除再重绘,并且中间间隔的时间还不能过长。
js 的 setTimeout() 方法,但是 setTimeoutsetInterval 的问题是,它们都不精确。它们的内在运行机制决定了时间间隔参数实际上只是指定了把动画代码添加到浏览器 UI 线程队列中以等待执行的时间。如果队列前面已经加入了其他任务,那动画代码就要等前面的任务完成后再执行。

另外一个函数 —— requestAnimationFrame()

window.requestAnimationFrame() 方法告诉浏览器,你希望执行动画,并请求浏览器调用指定的函数在下一次重绘之前更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。

requestAnimationFrame() 函数可以说是专门用来写动画的。
编写动画循环的关键是要知道延迟时间多长合适。一方面,循环间隔必须足够短,这样才能让不同的动画效果显得平滑流畅;另一方面,循环间隔还要足够长,这样才能确保浏览器有能力渲染产生的变化。

大多数电脑显示器的刷新频率是 60Hz,大概相当于每秒钟重绘 60 次。大多数浏览器都会对重绘操作加以限制,不超过显示器的重绘频率,因为即使超过那个频率用户体验也不会有提升。因此,最平滑动画的最佳循环间隔是 1000ms/60,约等于 16.6ms

requestAnimationFrame 采用系统时间间隔,保持最佳绘制效率,不会因为间隔时间过短,造成过度绘制,增加开销;也不会因为间隔时间太长,使动画卡顿不流畅,让各种网页动画效果能够有一个统一的刷新机制,从而节省系统资源,提高系统性能,改善视觉效果。

使用 requestAnimationFrame() 函数递归的调用 animate() 函数来实现动画的效果。

 function animate(){
        content.clearRect(0,0,WIDTH,HEIGHT)
        for(var i in round){
            round[i].move()
        }
        requestAnimationFrame(animate)
    }

创建 move() 函数

使用 move() 函数来改变 round 的 y 坐标
move() 方法写在 Round_item 的原型上,这样我们创建的每一个 round 都具有了 move() 方法。

move() 方法中,我们只需要改变 round 的 y 坐标即可,并且设置边界条件,当 y 坐标的值小于 -10(也可以是其他负值),代表该 round 已经超出了屏幕,这个时候我们要将其移动到屏幕的最底端,这样才能保证我们创建的粒子数不变,一直是 initRoundPopulation 的值。
这样就是一个粒子在不断地上升,上升到了最顶端再移动到最底端的循环过程,看起来像是有源源不断的粒子,但其实总数是不变的。

在 y 坐标的变化之后,我们还需要使用新的 y 坐标再来重新绘制一下该 round。

init() 中加入 animate()

我们想要实现动画的效果,还需要在 init() 中加入 animate() 函数。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        html, body {
            margin: 0;
            overflow: hidden;
            width: 100%;
            height: 100%;
            cursor: none;
            background: black;
        }
    </style>
</head>
<body>
<canvas id="canvas"></canvas>

<script>
    var ctx = document.getElementById('canvas'),
        content = ctx.getContext('2d'),
        round = [],
        WIDTH,
        HEIGHT,
        initRoundPopulation = 80;


    WIDTH = document.documentElement.clientWidth;
    HEIGHT = document.documentElement.clientHeight;

    ctx.width = WIDTH;
    ctx.height = HEIGHT;

    function Round_item(index, x, y) {
        this.index = index;
        this.x = x;
        this.y = y;
        this.r = Math.random() * 2 + 1;
        var alpha = (Math.floor(Math.random() * 10) + 1) / 10 / 2;
        this.color = "rgba(255,255,255," + alpha + ")";
    }

    Round_item.prototype.draw = function () {
        content.fillStyle = this.color;
        content.shadowBlur = this.r * 2;
        content.beginPath();
        content.arc(this.x, this.y, this.r, 0, 2 * Math.PI, false);
        content.closePath();
        content.fill();
    };

    function animate() {
        content.clearRect(0, 0, WIDTH, HEIGHT);

        for (var i in round) {
            round[i].move();
        }
        requestAnimationFrame(animate)
    }

    Round_item.prototype.move = function () {
        this.y -= 0.15;
        if (this.y <= -10) {
            this.y = HEIGHT + 10;
        }
        this.draw();
    };


    function init() {
        for (var i = 0; i < initRoundPopulation; i++) {
            round[i] = new Round_item(i, Math.random() * WIDTH, Math.random() * HEIGHT);
            round[i].draw();
        }
        animate();

    }

    init();
</script>
</body>
</html>

使你的鼠标和屏幕互动

image.png

鼠标移动,会在经过的地方创建一个圆,圆的半径由小变大,达到某个固定大小时该圆消失。圆的颜色也是在随机变化的

创建 Canvas 元素

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        * {
            padding: 0;
            margin: 0;
        }

        #canvas {
            background: #000;
        }
    </style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
        var canvas = document.getElementById('canvas'),
            ctx = canvas.getContext('2d'),
            WIDTH = canvas.width = document.documentElement.clientWidth,
            HEIGHT = canvas.height = document.documentElement.clientHeight,
            para = {
                num: 100,
                color: false,    //  颜色  如果是false 则是随机渐变颜色
                r: 0.9,          //   圆每次增加的半径 
                o: 0.09,         //      判断圆消失的条件,数值越大,消失的越快
                a: 1
            },
            color,
            color2,
            round_arr = [];     // 存放圆的数组 
</script>
</body>
</html>

onmousemove 事件

在鼠标移动的过程中,不断地在鼠标滑过的位置产生一个逐渐变大的圆。

Canvas 中创建动画的方式就是不断地清除屏幕内容然后重绘。

移动的轨迹是由一个一个的圆构成的,如果移动的速度过快的话,那么就可以明显看出一个一个的圆。

既然轨迹是由很多圆构成,那么我们就应该使用数组储存圆的信息(坐标、半径),然后在鼠标移动的时候将鼠标的位置信息储存在数组中。

所以在鼠标移动的过程我们首先要获得鼠标的坐标,然后将鼠标的坐标以及其他信息 push 到数组中去:

window.onmousemove = function(event){
                mouseX = event.clintX;
                mouseY = event.clientY;
                round_arr.push({
                    mouseX:mouseX,
                    mouseY:mouseY,
                    r:para.r,//设置半径每次增大的数值
                    o:l //判断圆消失的条件  数值越大  消失的越快
                })
            }

设置 color

已经将圆的相关信息储存在 round_arr 数组中了,现在要在 animate() 函数中将圆显示出来。
创建圆需要的坐标信息以及半径,我们在鼠标移动的事件中都已经将其 push 到 round_arr 数组中了,还有一个条件是需要设置的,那就是颜色。
para 参数中,我们可以看出,其中有设置 color 值。如果 color 值不为 false,那么设置的圆的颜色就是设置的 color 值;如果设置的 color 值为 false,那么圆的颜色就是随机的。

if(para.color){
                color2 = para.color
            }else{
                color = Math.random()*360
            }

那么怎么设置颜色的渐变呢?我们将 color 的颜色值依次增加一个增量。

if (!para.color) {
    color += .1;
    color2 = 'hsl(' + color + ',100%,80%)';
}

要让颜色一直改变,我们要将上面颜色改变的代码放在一个一直执行的函数。将上面改变颜色的代码放在animate() 函数中。

animate() 函数

一个一直在执行的函数,这个函数主要负责动画的 animate() 函数。从函数名就可以看出这个函数的作用,的确,我们需要在该函数中写动画。
清除屏幕再重新绘制,

ctx.clearRect(0, 0, WIDTH, HEIGHT);

接着使用 round_arr 数组中的数据将一个一个的圆绘制出来。

然后我们还需要一直执行这个函数:

window.requestAnimationFrame(animate);
  1. 创建 Canvas 元素,设置参数
  2. 鼠标移动事件,将坐标信息 push 到数组
  3. 设置颜色
  4. 设置动画 animate() 函数
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        * {
            padding: 0;
            margin: 0;
        }

        #canvas {
            background: #000;
        }
    </style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>


        var canvas = document.getElementById('canvas'),
            ctx = canvas.getContext('2d'),
            WIDTH = canvas.width = document.documentElement.clientWidth,
            HEIGHT = canvas.height = document.documentElement.clientHeight,
            para = {
                num: 100,
                color: false,    //  颜色  如果是false 则是随机渐变颜色
                r: 0.9,
                o: 0.09,         //  判断圆消失的条件,数值越大,消失的越快
                a: 1,

            },
            color,
            color2,
            round_arr = [];





        window.onmousemove = function (event) {

            mouseX = event.clientX;
            mouseY = event.clientY;

            round_arr.push({
                mouseX: mouseX,
                mouseY: mouseY,
                r: para.r,
                o: 1
            })
        };


        // 判断参数中是否设置了 color,如果设置了 color,就使用该值、
        // 如果参数中的 color 为 false,那么就使用随机的颜色
        if (para.color) {
            color2 = para.color;
        } else {
            color = Math.random() * 360;
        }

        function animate() {

            if (!para.color) {
                color += .1;
                color2 = 'hsl(' + color + ',100%,80%)';
            }

            ctx.clearRect(0, 0, WIDTH, HEIGHT);

            for (var i = 0; i < round_arr.length; i++) {

                ctx.fillStyle = color2;
                ctx.beginPath();
                ctx.arc( round_arr[i].mouseX ,round_arr[i].mouseY,round_arr[i].r,0, Math.PI * 2);
                ctx.closePath();
                ctx.fill();
                round_arr[i].r += para.r;
                round_arr[i].o -= para.o;

                if( round_arr[i].o <= 0){
                    round_arr.splice(i,1);
                    i--;
                }
            }

            window.requestAnimationFrame(animate);
        };

        animate();
</script>
</body>
</html>

image.png

canvas特效

  • 背景颜色不宜过多
  • 粒子数量多
  • 粒子在动
  • 能和鼠标进行交互

背景颜色

因为网站还是以阅读为主,所以网站的背景颜色要适合阅读,最好还是设置为传统的 “白纸黑字”,使用浅色颜色作为背景,同时饱和度不宜过高,最好设置透明度。

并且背景颜色最好是 1~2 种颜色,不要设置过多的颜色,不然会影响阅读。

背景颜色可以直接使用 CSS 样式设置,不需要使用 Canvas。

粒子在动

大多数用户比较喜欢动效,但是对于网页的背景来说,动作的幅度又不能太大,动作也不要过于复杂,只是一些简单的位移并且动作的幅度也要小一点,让用户的潜意识里面知道这些粒子在动就可以,不能使用户的全部注意力都在粒子上面而忽视了网页的内容

和鼠标进行交互

用户一般还喜欢自己的操作能够得到网页的响应,所以我们可以设置鼠标跟随的效果或者视觉差的效果,加上和鼠标交互的特效,会使用户感到你的网站与众不同。

使你的 Canvas 更加优雅

常见的 Canvas 优化方法

避免浮点数的坐标点

绘制图形时,长度与坐标应选取整数而不是浮点数,原因在于 Canvas 支持半个像素绘制。

会根据小数位实现插值算法实现绘制图像的反锯齿效果,如果没有必要请不要选择浮点数值。

使用多层画布去画一个复杂的场景

一般在游戏中这个优化方式会经常使用,但是在我们的背景特效中不经常使用,这个优化方式是将经常移动的元素和不经常移动的元素分层,避免不必要的重绘。

比如在游戏中,背景不经常变换和人物这些经常变换的元素分成不同的层,这样需要重绘的资源就会少很多。

用 CSS transform 特性缩放画布

如果你使用 lefttop 这些 CSS 属性来写动画的话,那么会触发整个像素渲染流程 —— paintlayoutcomposition

但是使用 transform 中的 translateX/Y 来切换动画,你将会发现,这并不会触发 paintlayout,仅仅会触发 composition 的阶段。

这是因为 transform 调用的是 GPU 而不是 CPU。

离屏渲染

名字听起来很复杂,什么离屏渲染,其实就是设置缓存,绘制图像的时候在屏幕之外的地方绘制好,然后再直接拿过来用,这不就是缓存的概念吗?!︿( ̄︶ ̄)︿.

建立两个 Canvas 标签,大小一致,一个正常显示,一个隐藏(缓存用的,不插入 DOM 中)。先将结果 draw 到缓存用的 canvas 上下文中,因为游离 Canvas 不会造成 UI 的渲染,所以它不会展现出来;再把缓存的内容整个裁剪再 draw 到正常显示用的 Canvas 上,这样能优化不少。

离屏渲染

使用之前的demo

离屏渲染的主要过程就是将一个一个的粒子先在屏幕之外创建出来,然后再使用 drawImage() 方法将其“放入”到我们的主屏幕中。

我们首先要在全局设置一个变量 useCache 来存放我们是否使用离屏渲染这种优化方式。

var useCache = true;

Round_item 方法

然后我们在 Round_item 原型的 draw() 方法中创建每一个离屏的小的 canvas

function Round_item(index,x,y){
            this.index = index;
            this.x = x;
            this.y = y;
            this.useCache = useChache;
            
            this.cacheCanvas = document.createElement('canvas')
            this.cacheCtx = this.cacheCanvas.getContext('2d')
            this.r = Math.random()*2 + 1;
            this.cacheCtx.width = 6* this.r;
            this.cacheCtx.height = 6*this.r;
            var alpha = (Math.floor(Math.random()*10)+1)/10/2;
            this.color = "rgba(255,255,255,"+ alpha+")";
            if(useChache){
                this.cache()
            }
        }

这里的 cacheCanvas 画布的宽度要设置为 6 倍的半径 是因为,我们创建的 cacheCanvas 不仅仅是有圆,还包括圆的阴影,所以我们要将 cacheCanvas 的面积设置得稍微大一些,这样才能将圆带阴影一起剪切到我们的主 Canvas 中。
draw() 方法中,我们新创建了 cacheCanvas,并获取到了 cacheCanvas 的上下文环境,然后设置其宽高。

然后我们判断了 useChache 变量的值,也就是说,如果我们将 useChache 设置为 true,也就是使用缓存,我们就调用 this.cache() 方法

this.cache() 方法

Round_item 的原型中设置 this.cache() 方法。

this.cache() 方法中,我们的主要任务是在每一个 cacheCanvas 中都绘制一个圆。

  Round_item.prototype.cache = function(){
            this.cacheCtx.save();
            this.cacheCtx.fillStyle = this.color;
            this.cacheCtx.shadowColor = "white";
            this.cacheCtx.shadowBlur = this.r*2;
            this.cacheCtx.beginPath();
            this.cacheCtx.arc(this.r*3,this.r*3,this.r,0,2*Math.PI);
            this.cacheCtx.closePath();
            this.cacheCtx.fill();
            // restore(): 用来恢复Canvas旋转、缩放等之后的状态  当和canvas.save( )一起使用时,恢复到canvas.save( )保存时的状态。
            this.cacheCtx.restore();
        }

draw() 方法中画的圆不同之处是,要注意这里设置的圆心坐标,是 this.r * 3,因为我们创建的 cacheCanvas 的宽度和高度都是 6 * this.r,我们的圆是要显示在 cacheCanvas 的正中心,所以设置圆心的坐标应该是 this.r * 3,this.r * 3

draw() 方法

draw() 中,就需要使用 Canvas 的 drawImage 方法将 cacheCanvas 中的内容显示在屏幕上。

 Round_item.prototype.draw = function(){
            if(!useCache){
                content.fillStyle = this.color
                content.shadowBlur = this.r*2
                content.beginPath()
                content.arc(this.x,this.y,this.r,0,2*Math.PI,false);
                content.closePath()
                content.fill()
            }else{
                // drawImage() 方法绘制图像的某些部分,以及/或者增加或减少图像的尺寸 在画布上定位图像,并规定图像的宽度和高度:
                content.drawImage(this.cacheCanvas,this.x-this.r,this.y-this.r)
            }
        }

如果没有使用缓存的话,还是使用最原始的创建圆的方式。

就完成了离屏渲染的优化,我们来一起看一下完整的代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>

        html,body{
            width: 100%;
            height: 100%;
            margin:0;
            overflow: hidden;
            cursor: none;
            background-color: #000;
        }
    </style>
</head>
<body>
    <canvas id="canvas"></canvas>
    <script>
        var ctx = document.getElementById('canvas'),
            content = ctx.getContext('2d'),
            round = [],
            WIDTH,HEIGHT,
            initRoundPopulation = 80,
            useChache = true;
            WIDTH = document.documentElement.clientWidth;
            HEIGHT = document.documentElement.clientHeight;
            ctx.width = WIDTH;
            ctx.height = HEIGHT;
            
            function Round_item(index,x,y){
                this.index = index;
                this.x = x;
                this.y = y;
                this.useChache = useChache;
                this.cacheCanvas = document.createElement('canvas');
                this.cacheCtx = this.cacheCanvas.getContext('2d');
                this.cacheCtx.width = 6*this.r;
                this.cacheCtx.height = 6*this.r;
                this.r = Math.random()*2 +1;
                var alpha = (Math.floor(Math.random()*10)+1)/10/2;
                this.color = "rgba(255,255,255,"+alpha+")";
                if(useChache){
                    this.cache()
                }
            }
            Round_item.prototype.draw = function(){
                if(!useChache){
                    content.fillStyle = this.color;
                    content.shadowBlur = this.r*2;
                    content.beginPath();
                    content.arc(this.x,this.y,this.r,0,2*Math.PI,false);
                    content.closePath()
                    content.fill()
                }else{
                    content.drawImage(this.cacheCanvas,this.x-this.r,this.y - this.r)
                }
            }
            Round_item.prototype.cache = function(){
                this.cacheCtx.save();
                this.cacheCtx.fillStyle = this.color;
                this.cacheCtx.shadowColor = "white";
                this.cacheCtx.shadowBlur = this.r*2;
                this.cacheCtx.beginPath();
                this.cacheCtx.arc(this.r*3,this.r*3,this.r,0,2*Math.PI);
                this.cacheCtx.closePath();
                this.cacheCtx.fill();
                this.cacheCtx.restore()
            };
            function animate(){
                content.clearRect(0,0,WIDTH,HEIGHT);
                for(var i in round){
                    round[i].move()
                }
                requestAnimationFrame(animate)
            };
            Round_item.prototype.move = function(){
                this.y -= 0.15;
                if(this.y <= -10){
                    this.y = HEIGHT + 10
                }
                this.draw()
            }
            function init(){
                for(var i = 0; i < initRoundPopulation;i++){
                    round[i] = new Round_item(i,Math.random()*WIDTH,Math.random()*HEIGHT);
                    round[i].draw()
                }
                animate()
            }
            init()
    </script>
</body>
</html>
查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 152 次点赞
  • 获得 9 枚徽章 获得 0 枚金徽章, 获得 3 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2019-03-18
个人主页被 3.2k 人浏览