JackySummer

JackySummer 查看完整档案

香港岛编辑  |  填写毕业院校  |  填写所在公司/组织 jacky-summer.github.io/ 编辑
编辑

微信公众号【前端精神时光屋】

个人动态

JackySummer 发布了文章 · 10月21日

TypeScript 入门知识点总结

TypeScript 介绍

什么是 TypeScript

是 JavaScript 的一个超集,它可以编译成纯 JavaScript。编译出来的 JavaScript 可以运行在任何浏览器上,主要提供了类型系统对 ES6 的支持

为什么选择 TypeScript

  • 增加了代码的可维护性
  • 包容性强,支持 ES6 语法,.js文件直接重命名为.ts即可
  • 兼容第三方库,即使第三方库不是 TypeScript 写的,也可以通过单独编写类型文件供识别读取
  • 社区活跃,目前三大框架还有越来越多的库都支持 TypeScript 了

TypeScript 只会在编译的时候对类型进行静态检查,如果发现有错误,编译的时候就会报错。

安装 TypeScript

  1. 全局安装 TS
npm i -g typescript
  1. 查看 TypeScript 版本号
tsc -v

我当前版本为 Version 4.0.2

  1. 初始化生成 tsconfig.json 文件
tsc --init
  1. 在 tsconfig.json 中设置源代码目录和编译生成 js 文件的目录
"outDir": "./dist",
"rootDir": "./src"
  1. 监听 ts 文件的变化,每当文件发生改变就自动编译
tsc -w

之后你写的 ts 文件编译错误都会直接提示,如果想运行文件,就到 /dist目录下找相应的 js 文件,使用 node 运行即可

ts-node 安装

当然这样其实也挺麻烦,我们想直接运行 TS 文件, 这时可以借助ts-node插件

全局安装

npm install -g ts-node

找到文件路径,运行即可

ts-node demo.ts

基础类型

Number 类型

let num: number = 2

Boolean 类型

let isShow: boolean = true

String 类型

let str: string = 'hello'

Array 类型

let arr1: number[] = [1, 2, 3]
let arr2: Array<number> = [2, 3, 4]

Any 类型

let foo: any = 'hello'
foo = 12
foo = false

Null 和 Undefined 类型

null 和 undefined 可以赋值给任意类型的变量

let test1: undefined = undefined
let test2: null = null
let test3: number
let test4: string
test3 = null
test4 = undefined

Void 类型

void 类型像是与 any 类型相反,它表示没有任何类型。当一个函数没有返回值时,其返回值类型是 void

let test5: void = undefined // 声明一个 void 类型的变量没有什么用,因为它的值只能为 undefined 或 null
function testFunc(): void {} // 函数没有返回值

Never 类型

never 类型表示的是那些永不存在的值的类型。

function bar(): never {
  throw new Error('never reach')
}

Unknown 类型

所有类型都可以赋值给 any,所有类型也都可以赋值给 unknown。

let value: unknown
value = 123
value = 'Hello'
value = true

let value1: unknown = value
let value2: any = value
let value3: boolean = value // Error
let value4: number = value // Error
let value5: string = value // Error
let value6: object = value // Error
let value7: any[] = value // Error
let value8: Function = value // Error

unknown 类型只能被赋值给 any 类型和 unknown 类型本身。

Tuple 类型

数组合并了相同类型的对象,而元组(Tuple)合并了不同类型的对象。元组表示一个数量和类型都已知的数组

let tupleArr1: [string, number] = ['hello', 10]
// let tupleArr2: [string, number] = [10, 'hello'] // Error

Enum 类型

使用枚举可以定义一些带名字的常量。 TypeScript 支持数字的和基于字符串的枚举。

  enum Season {
    Spring,
    Summer,
    Autumn,
    Winter,
  }
  let a: Season = Season.Spring
  let b: Season = Season.Summer
  let c: Season = Season.Autumn
  let d: Season = Season.Winter
  console.log(a, b, c, d) // 0 1 2 3
}

函数类型

函数声明

// JS
function func1(x, y) {
  return x + y
}
// TS
function func2(x: number, y: number): number {
  return x + y
}

函数表达式

在 TypeScript 的类型定义中,=> 用来表示函数的定义,左边是输入类型,需要用括号括起来,右边是输出类型。

// JS
let func3 = function (x, y) {
  return x + y
}
// TS 第一种方式
let func4 = function (x: number, y: number): number {
  return x + y
}
// TS 第二种方式
// => 用来表示函数的定义,左边是输入类型,需要用括号括起来,右边是输出类型。
let func5: (x: number, y: number) => number = function (x: number, y: number): number {
  return x + y
}

接口定义函数的形状

采用函数表达式|接口定义函数的方式时,对等号左侧进行类型限制,可以保证以后对函数名赋值时保证参数个数、参数类型、返回值类型不变。

interface SearchFunc {
  (source: string, subString: string): boolean
}

let mySearch: SearchFunc
mySearch = function (source: string, subString: string) {
  return source.search(subString) !== -1
}

可选参数

对于 TS 的函数,输入多余的(或者少于要求的)参数,是不允许的,那么该怎么设置可选参数呢?,我们可以在声明之后加一个问号

function getName1(firstName: string, lastName?: string) {
  // 可选参数必须接在必需参数后面
  if (lastName) {
    return `${firstName} ${lastName}`
  } else {
    return firstName
  }
}
let name1 = getName1('jacky')
let name2 = getName1('jacky', 'lin')
console.log(name1, name2)

参数默认值

function getName2(firstName: string = 'monkey', lastName: string) {
  return `${firstName} ${lastName}`
}
console.log(getName2('jacky', 'Lin'))
console.log(getName2(undefined, 'Lin')) // monkey Lin

void 和 never 类型

当函数没有返回值时,可以用 void 来表示。

当一个函数永远不会返回时,我们可以声明返回值类型为 never

function func6(): void {
  // return null
}

function func7(): never {
  throw new Error('never reach')
}

剩余参数

function push1(arr, ...items) {
  items.forEach(function (item) {
    arr.push(item)
  })
}
let a: any[] = []
push1(a, 1, 2, 3)

function push2(arr: any[], ...items: any[]): void {
  items.forEach(function (item) {
    arr.push(item)
  })
}
let b: any[] = []
push1(b, 1, 2, 3, '5')

函数参数为对象(解构)时

// js写法
function add({ one, two }) {
  return one + two
}

const total = add({ one: 1, two: 2 })

// ts 写法
function add1({ one, two }: { one: number; two: number }): number {
  return one + two
}

const three = add1({ one: 1, two: 2 })

函数重载

重载允许一个函数接受不同数量或类型的参数时,作出不同的处理。

function reverse(x: number): number // 函数定义
function reverse(x: string): string // 函数定义
function reverse(x: number | string): number | string {
  // 函数实现
  if (typeof x === 'number') {
    return Number(x.toString().split('').reverse().join(''))
  } else if (typeof x === 'string') {
    return x.split('').reverse().join('')
  }
}

类型断言

TypeScript 允许你覆盖它的推断,并且能以你任何你想要的方式分析它,这种机制被称为「类型断言」。

TypeScript 类型断言用来告诉编译器你比它更了解这个类型,你知道你自己在干什么,并且它不应该再发出错误。

interface Cat {
  name: string
  run(): void
}

interface Fish {
  name: string
  swim(): void
}

// 只能访问共有的属性
function getName(animal: Cat | Fish): string {
  return animal.name
}

// 将 animal 断言成 Fish 就可以解决访问 animal.swim 时报错的问题
function isFish(animal: Cat | Fish): boolean {
  if (typeof (animal as Fish).swim === 'function') {
    return true
  }
  return false
}

// 任何类型都可以被断言为 any
;(window as any).randomFoo = 1
// 类型断言第一种方式:"尖括号"语法
let value1: any = 'hello'
let value1Length: number = (<string>value1).length

// 类型断言第二种方式:as
let value2: any = 'world'
let value2Length: number = (value2 as string).length

存取器

class Animal {
  constructor(name: string) {
    this.name = name
  }
  // getter
  get name() {
    return '名字'
  }
  // setter
  set name(value: string) {
    console.log('setter: ' + value)
  }
}

let animal = new Animal('monkey')
console.log(animal.name) // monkey
animal.name = 'mk' // setter: mk

访问修饰符

  • public 修饰的属性或方法是公有的,可以在任何地方被访问到,默认所有的属性和方法都是 public 的
  • private 修饰的属性或方法是私有的,不能在声明它的类的外部访问
  • protected 修饰的属性或方法是受保护的,它和 private 类似,区别是它在子类中也是允许被访问的
class Person {
  public name
  private age
  protected sex
  public constructor(name: string, age: number, sex: string) {
    this.name = name
    this.age = age
    this.sex = sex
  }
}

let person1 = new Person('jacky', 22, 'man')
person1.name = 'monkey' // name值可以访问且修改
// person1.age // Property 'age' is private and only accessible within class 'Person'.
// person1.sex // Property 'sex' is private and only accessible within class 'Person'.

class Person1 extends Person {
  constructor(name: string, age: number, sex: string) {
    super(name, age, sex)
    // console.log(this.name, this.age, this.sex) // Property 'age' is private and only accessible within class 'Person'.
  }
}

参数属性

同时给类中定义属性的同时赋值

class Animal3 {
  public name
  constructor(name: string) {
    this.name = name
  }
}
console.log(new Animal3('animal3').name) // animal3

class Animal2 {
  constructor(public name: string) {} // 简洁形式
}
console.log(new Animal2('animal2').name) // animal2
  • readonly 只读属性
class Animal4 {
  readonly name
  constructor(name: string) {
    this.name = name
  }
}
let animal4 = new Animal4('animal4')
// animal4.name = '5' // Cannot assign to 'name' because it is a read-only property

抽象类

// 抽象类是行为的抽象,一般来封装公共属性的方法的,不能被实例化
abstract class CommonAnimal {
  name: string
  abstract speak(): void
}

static

class Animal5 {
  static sayHi() {
    console.log('Hello Animal5')
  }
}
Animal5.sayHi()

联合类型

联合类型(Union Types)表示取值可以为多种类型中的一种。使用一个|分割符来分割多种类型

let foo: string | number | boolean
foo = 'test'
foo = 3
foo = true

交叉类型

interface IPerson {
  id: string
  age: number
}

interface IWorker {
  companyId: string
}

type IStaff = IPerson & IWorker

const staff: IStaff = { id: '007', age: 24, companyId: '1' }

类型别名

type Message = string | string[]

let getMsg = (message: Message) => {
  return message
}

type Weather = 'SPRING' | 'SUMMER' | 'AUTUMN' | 'WINTER'
let weather1: Weather = 'SPRING'
let weather2: Weather = 'AUTUMN'

接口

对象的形状

interface Person1 {
  name: string
  age: number
}
let person1: Person1 = {
  name: 'jacky',
  age: 23,
}

描述行为的抽象

interface AnimalLike {
  eat(): void
  move(): void
}

interface PersonLike extends AnimalLike {
  speak(): void
}

class Human implements PersonLike {
  speak() {}
  eat() {}
  move() {}
}

含构建函数作参数的写法

class Animal1 {
  constructor(public name: string) {}
  age: number
}

class Animal2 {
  constructor(public age: number) {}
}

interface WithNameClass {
  new (name: string): Animal1
}

function createClass(classname: WithNameClass, name: string) {
  return new classname(name)
}

let instance1 = createClass(Animal1, 'monkey')
// let instance2 = createClass(Animal2, 'monkey') // 没有name属性则报错

其它任意属性

interface Person2 {
  readonly id: number
  [propName: string]: any //任意属性
}

泛型

泛型是指定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性;通常用 T 表示,但不是必须使用改字母,只是常规,通常还有其他常用字母:

  • T(Type):表示一个 TypeScript 类型
  • K(Key):表示对象中的键类型
  • V(Value):表示对象中的值类型
  • E(Element):表示元素类型

泛型类

class GenericNumber<T> {
  name: T
  add: (x: T, y: T) => T
}

let generic = new GenericNumber<number>()
generic.name = 123

泛型数组

  • 写法一
function func<T>(params: T[]) {
  return params
}
func<string>(['1', '2'])
func<number>([1, 2])
  • 写法二
function func1<T>(params: Array<T>) {
  return params
}
func1<string>(['1', '2'])
func1<number>([1, 2])

泛型接口

可以用来约束函数

interface Cart<T> {
  list: T[]
}
let cart: Cart<number> = { list: [1, 2, 3] }

泛型别名

type Cart2<T> = { list: T[] } | T[]
let c1: Cart2<number> = { list: [1, 2, 3] }
let c2: Cart2<number> = [1, 2, 3]

泛型接口 VS 泛型别名

  • 接口创建了一个新的名字,它可以在其他任意地方被调用。而类型别名并不创建新的名字
  • 类型别名不能被 extends 和 implements,这时我们应该尽量使用接口代替类型别名
  • 当我们需要使用联合类型或者元组类型的时候,类型别名会更合适

多个泛型

// 不借助中间变量交换两个变量的值
function swap<T, P>(tuple: [T, P]): [P, T] {
  return [tuple[1], tuple[0]]
}

let ret = swap([1, 'a'])
ret[0].toLowerCase()
ret[1].toFixed(2)

默认泛型

function createArray<T = number>(length: number, value: T): T[] {
  let arr: T[] = []
  for (let i = 0; i < length; i++) {
    arr[i] = value
  }
  return arr
}
let arr = createArray(3, 9)

泛型约束(继承)

interface WithLength {
  length: number
}
// extends 来继承
function logger<T extends WithLength>(val: T) {
  console.log(val.length)
}
logger('hello')
logger([1, 2, 3])
// logger(true) // error 没有length属性

泛型工具类型

为了方便开发者 TypeScript 内置了一些常用的工具类型,比如 Partial、Required、Readonly、Pick 等。它们都是使用 keyof 实现。

keyof 操作符可以用来一个对象中的所有 key 值。

interface Person1 {
  name: string
  age: number
  sex?: string
}
type PersonKey = keyof Person1 // 限制 key 值的取值
function getValueByKey(p: Person1, key: PersonKey) {
  return p[key]
}

内置的工具类型

// type Partial<T> = {[P in keyof T]?: T[P]}
type PersonSearch = Partial<Person> // 全部变可选

// type Required<T> = { [P in keyof T]-?: T[P] }
type PersonRequired = Required<Person> // 全部变必选

// type ReadOnly<T> = { readonly [P in keyof T]: T[P] }
type PersonReadOnly = Readonly<Person> // 全部变只读

// type Pick<T, K extends keyof T>  = {[P in K]: T[P]}
type PersonSub = Pick<Person, 'name'> // 通过从Type中选择属性Keys的集合来构造类型。

类数组

let root = document.getElementById('root')
let children: HTMLCollection = root!.children // ! 代表非空断言操作符
let childNodes: NodeListOf<ChildNode> = root!.childNodes

类型保护

更明确的判断某个分支作用域中的类型,主要尝试检测属性、方法或原型,以确定如何处理值。

typeof

function double(input: string | number | boolean): number {
  // 基本数据类型的类型保护
  if (typeof input === 'string') {
    return input.length
  } else if (typeof input === 'number') {
    return input
  } else {
    return 0
  }
}

instanceof

class Monkey {
  climb: string
}
class Person {
  sports: string
}
function getAnimalName(animal: Monkey | Person) {
  if (animal instanceof Monkey) {
    console.log(animal.climb)
  } else {
    console.log(animal.sports)
  }
}

in

class Student {
  name: string
  play: string[]
}
class Teacher {
  name: string
  teach: string
}
type SchoolRole = Student | Teacher
function getRoleInformation(role: SchoolRole) {
  if ('play' in role) {
    console.log(role.play)
  }
  if ('teach' in role) {
    console.log(role.teach)
  }
}

自定义类型保护

比如有时两个类型有不同的取值,也没有其他可以区分的属性

interface Bird {
  leg: number
}
interface Dog {
  leg: number
}
function isBird(x: Bird | Dog): x is Bird {
  return x.leg === 2
}
function getAnimal(x: Bird | Dog): string {
  if (isBird(x)) {
    return 'bird'
  } else {
    return 'dog'
  }
}

上述完整代码示例:typescript-tutorial


查看原文

赞 0 收藏 0 评论 0

JackySummer 发布了文章 · 10月18日

如何编写一个 Webpack Plugin

前言

上次写了 如何编写一个 Webpack Loader,今天来说说如何编写一个 Webpack Plugin。

webpack 内部执行流程

一次完整的 webpack 打包大致是这样的过程:

  • 将命令行参数与 webpack 配置文件 合并、解析得到参数对象。
  • 参数对象传给 webpack 执行得到 Compiler 对象。
  • 执行 Compiler 的 run 方法开始编译。每次执行 run 编译都会生成一个 Compilation 对象。
  • 触发 Compiler 的 make 方法分析入口文件,调用 compilation 的 buildModule 方法创建主模块对象。
  • 生成入口文件 AST(抽象语法树),通过 AST 分析和递归加载依赖模块。
  • 所有模块分析完成后,执行 compilation 的 seal 方法对每个 chunk 进行整理、优化、封装。
  • 最后执行 Compiler 的 emitAssets 方法把生成的文件输出到 output 的目录中。

Plugin 作用

按我的理解,Webpack 插件的作用就是在 webpack 运行到某个时刻的时候,帮我们做一些事情。

在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

官方解释是:

插件向第三方开发者提供了 webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以引入它们自己的行为到 webpack 构建流程中。

编写 Plugin

webpack 插件的组成:

  • 一个 JS 命名函数或一个类(可以想下我们平时使用插件就是 new XXXPlugin()的方式)
  • 在插件类/函数的 (prototype) 上定义一个 apply 方法。
  • 通过 apply 函数中传入 compiler 并插入指定的事件钩子,在钩子回调中取到 compilation 对象
  • 通过 compilation 处理 webpack 内部特定的实例数据
  • 如果是插件是异步的,在插件的逻辑编写完后调用 webpack 提供的 callback

比如我们写一个插件,生成一个版权的文件。

基本雏形

function CopyrightWebpackPlugin() {}

CopyrightWebpackPlugin.prototype.apply = function (compiler) {}

module.exports = CopyrightWebpackPlugin

也可以写成类的形式:

class CopyrightWebpackPlugin {
  apply(compiler) {
    console.log(compiler)
  }
}

module.exports = CopyrightWebpackPlugin

webpack 在启动之后,在读取配置的过程中会先执行new CopyrightWebpackPlugin(options)操作,初始化一个CopyrightWebpackPlugin实例对象。在初始化 compiler 对象之后,会调用上述实例对象的apply方法并将compiler对象传入。

apply方法中,通过compiler对象来监听 webpack 生命周期中广播出来的事件,我们也可以通过 compiler 对象来操作 webpack 的输出。

Compiler 和 Compilation

在插件开发中最重要的两个对象是 compilercompilation 对象。

compiler 对象代表了完整的 webpack 环境配置,在初始化 compiler 对象之后,通过调用插件实例的 apply 方法,作为其参数传入。这个对象在启动 webpack 时被一次性建立,并包含了 webpack 环境的所有的配置信息,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。

compilation 对象会作为 plugin 内置事件回调函数的参数,一个 compilation 对象包含了当前的模块资源、编译生成资源、变化的文件以及被跟踪依赖的状态信息。当 Webpack 以开发模式运行时,每当检测到一个文件变化,一次新的 compilation 将被创建。compilation 对象也提供了很多事件回调供插件做扩展。通过 compilation 也能读取到 compiler 对象。

编码

下面代码为生成一个版权 txt 文件,新建文件src/plugins/copyright-webpack-plugin.js

class CopyrightWebpackPlugin {
  apply(compiler) {
    // emit 钩子是生成资源到 output 目录之前执行,emit 是一个异步串行钩子,需要用 tapAsync 来注册
    compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, callback) => {
      // 回调方式注册异步钩子
      const copyrightText = '版权归 JackySummer 所有'
      // compilation存放了这次打包的所有内容
      // 所有待生成的文件都在它的 assets 属性上
      compilation.assets['copyright.txt'] = {
        // 添加copyright.txt
        source: function () {
          return copyrightText
        },
        size: function () {
          // 文件大小
          return copyrightText.length
        },
      }
      callback() // 必须调用
    })
  }
}

module.exports = CopyrightWebpackPlugin

webpack 中许多对象扩展自 Tapable 类。这个类暴露 tap, tapAsync 和 tapPromise 方法,可以使用这些方法,注入自定义的构建步骤,这些步骤将在整个编译过程中不同时机触发。

使用 tapAsync 方法来访问插件时,需要调用作为最后一个参数提供的回调函数。

在 webpack.config.js

const path = require('path')
const CopyrightWebpackPlugin = require('./src/plugins/copyright-webpack-plugin')

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  plugins: [new CopyrightWebpackPlugin()],
}

执行 webpack 命令,就会看到 dist 目录下生成copyright.txt文件

如果在配置文件使用 plugin 时传入参数该怎么获得呢,可以在插件类添加构造函数拿到:

 plugins: [
  new CopyrightWebpackPlugin({
    name: 'jacky',
  }),
],

copyright-webpack-plugin.js

class CopyrightWebpackPlugin {
  constructor(options = {}) {
    console.log('options', options) // options { name: 'jacky' }
  }
}

参考文章: 揭秘 webpack plugin


查看原文

赞 0 收藏 0 评论 0

JackySummer 发布了文章 · 10月13日

如何编写一个 Webpack Loader

前言

在平时自己由零搭建项目时,虽然基础配置都比较熟悉,比如配置 file-loader, url-loader, css-loader 等,配置不难,但究竟是怎么起作用的呢,今天就来说说如何编写一个 Webpack Loader。

Loader 作用

按我自己的简单理解,loader 通常指打包的方案,即按什么方式来处理打包,打包的时候它可以拿到模块源代码,经过特定 loader 的转换后返回新的结果。

比如 sass-loader 可以把 SCSS 代码转换成 CSS 代码

编写 Loader

保持功能单一

我们项目中可能会配置很多,但要记住,要保持一个 Loader 的功能单一,避免做多种功能,只需完成一种功能转换即可。

所以如 less 文件转换成 css 文件,也不是一步到位,而是 less-loader, css-loader, style-loader 几个 loader 的链式调用才能完成转换。

模块

因为 Webpack 本身是运行在 Node.js 之上的,一个 loader 其实就是一个 node 模块,这个模块导出的是一个函数,即:

module.exports = function (source) {
  // source 为 compiler 传递给 Loader 的一个文件的原内容
  // 处理...
  return source // 需要返回处理后的内容
}

这个导出的函数的工作就是获得处理前的原内容,对原内容执行处理后,返回处理后的内容。

替换字符串的 loader

比如我们打包时,想要替换源文件的字符串,这时可以考虑使用 Loader,因为 loader 就是获得源文件内容然后对其进行处理,再返回。

比如 src 目录下有三个文件:

src/msg1.js

export const msg1 = '学习框架'

src/msg2.js

export const msg2 = '深入理解JS'

src/index.js

import { msg1 } from './msg1'
import { msg2 } from './msg2'

function print() {
  console.log(`输出:${msg1}, ${msg2}`)
}

print()

做的事情则是把 msg1 和 msg2 两个文件导入,然后输出两个字符串。

我们要做的事也很简单,把"框架"转为"React 框架", "JS"转为"JavaScript"。

新建 src/loaders/replaceLoader.js文件,

module.exports = function (source) {
  const handleContent = source.replace('框架', 'React框架').replace('JS', 'JavaScript')
  return handleContent
}

就这样,loader 写完了!!!

上面我们讲到,source 是源文件内容,如果打印的话,则是:

使用 Loader

接下来,我们要来使用它,在根目录下新建文件 webpack.config.js

const path = require('path')

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  module: {
    rules: [
      {
        test: /\.js$/,
        use: './src/loaders/replaceLoader.js',
      },
    ],
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
}

执行npx webpack,
查看打包结果dist/main.js

(()=>{"use strict";console.log("输出:学习React框架, 深入理解JavaScript")})();

替换成功!

需要注意的是,use里面填写的 loader 是去node_modules目录里面找的,由于我们是自定义的 loader,所以不能直接写use: 'replaceLoader',但直接写路径的方式未免难看点,我们可以通过 webpack 来配置:

module.exports = {
  resolveLoader: {
    modules: ['node_modules', './src/loaders'], // node_modules找不到,就去./src/loaders找
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'replaceLoader',
      },
    ],
  },
}

获取 loader 的 options

写完之后,让我们来想想,其实就是写一个功能函数嘛。

当然,这只是最简单的例子,如果 loader 可以传入参数呢,比如:

module: {
  rules: [
    {
      test: /\.js$/,
      use: {
        loader: 'replaceLoader',
        options: {
          params: 'replaceString',
        },
      },
    },
  ],
},

这个时候可以使用this.query来获取,通过this.query.params就能拿到,这里需要注意的是,this 上下文是有用的,所以这个 loader 导出函数不能是箭头函数。

但 webpack 更推荐loader-utils模块来获取,它提供了许多有用的工具,最常用的一种工具是获取传递给 loader 的选项。

首先要安装

npm i -D loader-utils

修改src/loaders/replaceLoader.js

const { getOptions } = require('loader-utils')

module.exports = function (source) {
  console.log(getOptions(this)) // { params: 'replaceString' }
  console.log(this.query.params) // replaceString
  const handleContent = source.replace('框架', 'React框架').replace('JS', 'JavaScript')
  return handleContent
}

这里需要注意的是,getOptions(this)参数传入的是 this,也就是说

打印结果:

{ params: 'replaceString' }
{ params: 'replaceString' }
{ params: 'replaceString' }

this.callback()

上面都是返回原来内容转换后的内容,但有些场景下还需要返回其他东西比如 sourceMap

module.exports = function (source) {
  // 告诉 Webpack 返回的结果
  this.callback(null, source, sourceMaps)
}

另外也不需要 return 了,所以也可使用此 API 替代 return

const { getOptions } = require('loader-utils')

module.exports = function (source) {
  const handleContent = source.replace('框架', 'React框架').replace('JS', 'JavaScript')
  this.callback(null, handleContent)
}

自定义 loader 应用场景

  1. 在所有 function 外面加一层 try catch 代码块捕获错误,避免手动繁琐添加。
  2. 实现中英文替换:可以将文字用占位符如{{ title }}包裹,检测到占位符则根据环境变量替换为中英文。


查看原文

赞 0 收藏 0 评论 0

JackySummer 发布了文章 · 10月10日

手写简易的 Vue Router

前言

还是那样,懂得如何使用一个常用库,还得了解其原理或者怎么模拟实现,今天实现一下 vue-router

有一些知识我这篇文章提到了,这里就不详细一步步写,请看我 手写一个简易的 Vuex

基本骨架

  • Vue 里面使用插件的方式是Vue.use(plugin),这里贴出它的用法:
安装 Vue.js 插件。如果插件是一个对象,必须提供 install 方法。如果插件是一个函数,它会被作为 install 方法。install 方法调用时,会将 Vue 作为参数传入。这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象。
  • 全局混入

使用 Vue.mixin(mixin)

全局注册一个混入,影响注册之后所有创建的每个 Vue 实例。可以使用混入向组件注入自定义的行为,它将影响每一个之后创建的 Vue 实例。
  • 路由用法

比如简单的:

// 路由数组
const routes = [
  {
    path: '/',
    name: 'Page1',
    component: Page1,
  },
  {
    path: '/page2',
    name: 'Page2',
    component: Page2,
  },
]

const router = new VueRouter({
  mode: 'history', // 模式
  routes,
})

它是传入了moderoutes,我们实现的时候需要在VueRouter构造函数中接收。

在使用路由标题的时候是这样:

<p>
  <!-- 使用 router-link 组件来导航. -->
  <!-- 通过传入 `to` 属性指定链接. -->
  <!-- <router-link> 默认会被渲染成一个 `<a>` 标签 -->
  <router-link to="/page1">Go to Foo</router-link>
  <router-link to="/page2">Go to Bar</router-link>
</p>
<!-- 路由出口 -->
<!-- 路由匹配到的组件将渲染在这里 -->
<router-view></router-view>

故我们需要使用Vue.component( id, [definition] )注册一个全局组件。

了解了大概,我们就可以写出一个基本骨架

let Vue = null

class VueRouter {
  constructor(options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
  }
}

VueRouter.install = function (_Vue) {
  Vue = _Vue

  Vue.mixin({
    beforeCreate() {
      // 根组件
      if (this.$options && this.$options.router) {
        this._root = this // 把当前vue实例保存到_root上
        this._router = this.$options.router // 把router的实例挂载在_router上
      } else if (this.$parent && this.$parent._root) {
        // 子组件的话就去继承父组件的实例,让所有组件共享一个router实例
        this._root = this.$parent && this.$parent._root
      }
    },
  })

  Vue.component('router-link', {
    props: {
      to: {
        type: [String, Object],
        required: true,
      },
      tag: {
        type: String,
        default: 'a', // router-link 默认渲染成 a 标签
      },
    },
    render(h) {
      let tag = this.tag || 'a'
      return <tag href={this.to}>{this.$slots.default}</tag>
    },
  })

  Vue.component('router-view', {
    render(h) {
      return h('h1', {}, '视图显示的地方') // 暂时置为h1标签,下面会改
    },
  })
}

export default VueRouter

mode

vue-router有两种模式,默认为 hash 模式。

history 模式

通过window.history.pushStateAPI 来添加浏览器历史记录,然后通过监听popState事件,也就是监听历史记录的改变,来加载相应的内容。

  • popstate 事件
当活动历史记录条目更改时,将触发 popstate 事件。如果被激活的历史记录条目是通过对 history.pushState()的调用创建的,或者受到对 history.replaceState()的调用的影响,popstate 事件的 state 属性包含历史条目的状态对象的副本。
  • History.pushState()方法
window.history.pushState(state, title, url)

该方法用于在历史中添加一条记录,接收三个参数,依次为:

state:一个与添加的记录相关联的状态对象,主要用于popstate事件。该事件触发时,该对象会传入回调函数。也就是说,浏览器会将这个对象序列化以后保留在本地,重新载入这个页面的时候,可以拿到这个对象。如果不需要这个对象,此处可以填null。
title:新页面的标题。但是,现在所有浏览器都忽视这个参数,所以这里可以填空字符串。
url:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个网址。

hash 模式

使用 URL 的 hash 来模拟一个完整的 URL。,通过监听hashchange事件,然后根据hash值(可通过 window.location.hash 属性读取)去加载对应的内容的。

继续增加代码,

let Vue = null

class HistoryRoute {
  constructor() {
    this.current = null // 当前路径
  }
}

class VueRouter {
  constructor(options) {
    this.mode = options.mode || 'hash'
    this.routes = options.routes || []
    this.routesMap = this.createMap(this.routes)
    this.history = new HistoryRoute() // 当前路由
    this.initRoute() // 初始化路由函数
  }

  createMap(routes) {
    return routes.reduce((pre, current) => {
      pre[current.path] = current.component
      return pre
    }, {})
  }

  initRoute() {
    if (this.mode === 'hash') {
      // 先判断用户打开时有没有hash值,没有的话跳转到 #/
      location.hash ? '' : (location.hash = '/')
      window.addEventListener('load', () => {
        this.history.current = location.hash.slice(1)
      })
      window.addEventListener('hashchange', () => {
        this.history.current = location.hash.slice(1)
      })
    } else {
      // history模式
      location.pathname ? '' : (location.pathname = '/')
      window.addEventListener('load', () => {
        this.history.current = location.pathname
      })
      window.addEventListener('popstate', () => {
        this.history.current = location.pathname
      })
    }
  }
}

VueRouter.install = function (_Vue) {
  Vue = _Vue

  Vue.mixin({
    beforeCreate() {
      if (this.$options && this.$options.router) {
        this._root = this
        this._router = this.$options.router
        Vue.util.defineReactive(this, '_route', this._router.history) // 监听history路径变化
      } else if (this.$parent && this.$parent._root) {
        this._root = this.$parent && this.$parent._root
      }
      // 当访问this.$router时即返回router实例
      Object.defineProperty(this, '$router', {
        get() {
          return this._root._router
        },
      })
      // 当访问this.$route时即返回当前页面路由信息
      Object.defineProperty(this, '$route', {
        get() {
          return this._root._router.history.current
        },
      })
    },
  })
}

export default VueRouter

router-link 和 router-view 组件

VueRouter.install = function (_Vue) {
  Vue = _Vue

  Vue.component('router-link', {
    props: {
      to: {
        type: [String, Object],
        required: true,
      },
      tag: {
        type: String,
        default: 'a',
      },
    },
    methods: {
      handleClick(event) {
        // 阻止a标签默认跳转
        event && event.preventDefault && event.preventDefault()
        let mode = this._self._root._router.mode
        let path = this.to
        this._self._root._router.history.current = path
        if (mode === 'hash') {
          window.history.pushState(null, '', '#/' + path.slice(1))
        } else {
          window.history.pushState(null, '', path.slice(1))
        }
      },
    },
    render(h) {
      let mode = this._self._root._router.mode
      let tag = this.tag || 'a'
      let to = mode === 'hash' ? '#' + this.to : this.to
      console.log('render', this.to)
      return (
        <tag on-click={this.handleClick} href={to}>
          {this.$slots.default}
        </tag>
      )
      // return h(tag, { attrs: { href: to }, on: { click: this.handleClick } }, this.$slots.default)
    },
  })

  Vue.component('router-view', {
    render(h) {
      let current = this._self._root._router.history.current // current已经是动态响应
      let routesMap = this._self._root._router.routesMap
      return h(routesMap[current]) // 动态渲染对应组件
    },
  })
}

至此,一个简易的vue-router就实现完了,案例完整代码附上:mini-vue-router


查看原文

赞 0 收藏 0 评论 0

JackySummer 发布了文章 · 10月8日

手写简易的 Vuex

前言

本文适合使用过 Vuex 的人阅读,来了解下怎么自己实现一个 Vuex。

基本骨架

这是本项目的src/store/index.js文件,看看一般 vuex 的使用

import Vue from 'vue'
import Vuex from './myvuex' // 引入自己写的 vuex
import * as getters from './getters'
import * as actions from './actions'
import state from './state'
import mutations from './mutations'

Vue.use(Vuex) // Vue.use(plugin)方法使用vuex插件

// vuex 导出一个类叫Store,并传入对象作为参数
export default new Vuex.Store({
  state,
  mutations,
  actions,
  getters,
})

Vue.use的用法:

安装 Vue.js 插件。如果插件是一个对象,必须提供 install 方法。如果插件是一个函数,它会被作为 install 方法。install 方法调用时,会将 Vue 作为参数传入。这个方法的第一个参数是 Vue 构造器,第二个参数是一个可选的选项对象。
  • 该方法需要在调用 new Vue() 之前被调用。
  • 当 install 方法被同一个插件多次调用,插件将只会被安装一次。

即是我们需要在./myvuex.js中导出 install方法,同时导出一个类Store,于是第一步可以写出代码:

let Vue = null

class Store {
  constructor(options) {}
}

function install(_Vue) {
  Vue = _Vue // 上面Store类需要能获取到Vue
}

export default {
  Store,
  install,
}

install 方法

当我们使用 vuex 的时候,每一个组件上面都有一个this.$store属性,里面包含了 state,mutations, actions, getters 等,所以我们也需要在每个组件上都挂载一个&dollar;store 属性,要让每一个组件都能获取到,这里我们使用Vue.mixin(mixin),用法介绍如下:

全局注册一个混入,影响注册之后所有创建的每个 Vue 实例。可以使用混入向组件注入自定义的行为,它将影响每一个之后创建的 Vue 实例。
function install(_Vue) {
  Vue = _Vue // install方法调用时,会将Vue作为参数传入(上面Store类需要用到Vue)
  // 实现每一个组件,都能通过this调用$store
  Vue.mixin({
    beforeCreate() {
      // 通过this.$options可以获取new Vue({参数}) 传递的参数
      if (this.$options && this.$options.store) {
        // 证明这个this是根实例,也就是new Vue产生的那个实例
        this.$store = this.$options.store
      } else if (this.$parent && this.$parent.$store) {
        // 子组件获取父组件的$store属性
        this.$store = this.$parent.$store
      }
    },
  })
}

state

由于 Vuex 是基于 Vue 的响应式原理基础,所以我们要让数据改变可刷新视图,则需要创建一个 vue 实例
由于

class Store {
  // options 即是 Vuex.Store({})传入的参数
  constructor(options) {
    // vuex 的核心就是借用了vue的实例,因为vue的实例数据变化,会刷新视图
    let vm = new Vue({
      data: {
        state: options.state,
      },
    })
    // state
    this.state = vm.state
  }
}

commit

我们使用 vuex 改变数据时,是触发 commit 方法,即是这样使用的:

this.$store.commit('eventName', '参数' );

所以我们要实现一个commit方法,把 Store 构造函数传入的 mutations 做下处理

class Store {
  constructor(options) {
    // 实现 state ...

    // mutations
    this.mutations = {} // 存储传进来的mutations
    let mutations = options.mutations || {}
    // 循环取出事件名进行处理(mutations[事件名]: 执行方法)
    Object.keys(mutations).forEach(key => {
      this.mutations[key] = params => {
        mutations[key].call(this, this.state, params) // 修正this指向
      }
    })
  }

  commit = (key, params) => {
    // key为要触发的事件名
    this.mutations[key](params)
  }
}

dispatch

跟上面的 commit 流程同理

class Store {
  constructor(options = {}) {
    // ...

    // actions
    this.actions = {}
    let actions = options.actions || {}
    Object.keys(actions).forEach(key => {
      this.actions[key] = params => {
        actions[key].call(this, this, params)
      }
    })
  }

  dispatch = (type, payload) => {
    this.actions[type](payload)
  }
}

getters

getters 实际就是返回 state 的值,在使用的时候是放在 computed 属性,每一个 getter 都是函数形式;

getters 是需要双向绑定的。但不需要双向绑定所有的 getters,只需要绑定项目中事件使用的 getters。

这里使用Object.defineProperty()方法,它会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

class Store {
  constructor(options = {}) {
    // ...

    // getters
    this.getters = {}
    let getters = options.getters || {}
    Object.keys(getters).forEach(key => {
      Object.defineProperty(this.getters, key, {
        get: () => {
          return getters[key].call(this, this.state)
        },
      })
    })
  }
}

到此为止,已经可以使用我们自己写的 vuex 做一些基本操作了,但只能通过this.$store.xx的形式调用,故需要再实现方法。

map 辅助函数

先来说说 mapState

没有 map 辅助函数之前这样使用:

computed: {
  count () {
    return this.$store.state.count
  }
}

当映射的计算属性的名称与 state 的子节点名称相同时,给 mapState 传一个字符串数组。

computed: {
  // 使用对象展开运算符将此对象混入到外部对象中
  ...mapState(['count'])
}

我们这里简单就只实现数组的情况

export const mapState = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function () {
      return this.$store.state[item]
    }
  })
  return obj
}

之后几个 map 辅助函数都是类似

  • mapGetters
export const mapGetters = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function () {
      return this.$store.getters[item]
    }
  })
  return obj
}
  • mapMutations
export const mapMutations = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function (params) {
      return this.$store.commit(item, params)
    }
  })
  return obj
}
  • mapActions
export const mapActions = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function (payload) {
      return this.$store.dispatch(item, payload)
    }
  })
  return obj
}

完整代码

let Vue = null

class Store {
  constructor(options) {
    // vuex 的核心就是借用了vue的实例,因为vue的实例数据变化,会刷新视图
    let vm = new Vue({
      data: {
        state: options.state,
      },
    })
    // state
    this.state = vm.state

    // mutations
    this.mutations = {} // 存储传进来的mutations
    let mutations = options.mutations || {}
    Object.keys(mutations).forEach(key => {
      this.mutations[key] = params => {
        mutations[key].call(this, this.state, params)
      }
    })

    // actions
    this.actions = {}
    let actions = options.actions || {}
    Object.keys(actions).forEach(key => {
      this.actions[key] = params => {
        actions[key].call(this, this, params)
      }
    })

    // getters
    this.getters = {}
    let getters = options.getters || {}
    Object.keys(getters).forEach(key => {
      Object.defineProperty(this.getters, key, {
        get: () => {
          return getters[key].call(this, this.state)
        },
      })
    })
  }

  commit = (key, params) => {
    this.mutations[key](params)
  }

  dispatch = (type, payload) => {
    this.actions[type](payload)
  }
}

export const mapState = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function () {
      return this.$store.state[item]
    }
  })
  return obj
}

export const mapGetters = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function () {
      return this.$store.getters[item]
    }
  })
  return obj
}

export const mapMutations = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function (params) {
      return this.$store.commit(item, params)
    }
  })
  return obj
}

export const mapActions = args => {
  let obj = {}
  args.forEach(item => {
    obj[item] = function (payload) {
      return this.$store.dispatch(item, payload)
    }
  })
  return obj
}

function install(_Vue) {
  Vue = _Vue // install方法调用时,会将Vue作为参数传入(上面Store类需要用到Vue)
  // 实现每一个组件,都能通过this调用$store
  Vue.mixin({
    beforeCreate() {
      // 通过this.$options可以获取new Vue({参数}) 传递的参数
      if (this.$options && this.$options.store) {
        // 证明这个this是根实例,也就是new Vue产生的那个实例
        this.$store = this.$options.store
      } else if (this.$parent && this.$parent.$store) {
        // 子组件获取父组件的$store属性
        this.$store = this.$parent.$store
      }
    },
  })
}

export default {
  Store,
  install,
}

整个项目的源码地址:mini-vuex


查看原文

赞 0 收藏 0 评论 0

JackySummer 赞了回答 · 9月29日

解决初次接触better-scroll,想请问前辈,为什么better-scroll初始化后就是不能滚动?

遇到这个问题开始以为是版本原因,1.5和2.0都尝试了,但都是首次进入页面不能滚动必须刷新,设置的父元素和子元素高度是满足条件的,于是开始到处使用scroll.refresh(),跟上面各位老哥说的差不多,watch,activated,mounted,$nextTick全都用了,测试也确实执行了函数但还是没解决问题,如果这样还不行你可以试试加上这两行代码 。我加上以后根本不用使用refresh也好了,只是还不知道底层逻辑怎么判定的
image

关注 8 回答 7

JackySummer 发布了文章 · 9月21日

手写符合 Promises/A+ 规范的 Promise

前言

Promise 在开发中我们经常用到,它解决了回调地狱问题,对错误的处理也非常方便。本文我将通过一步步完善 Promise,从简单到复杂的过程来说。

本文适合熟练运用 Promise 的人阅读。

极简版

首先Promise是一个类,类中的构造函数需要接收一个执行函数executor默认就会执行,它有两个参数:resolvereject,这两个参数是Promise内部定义的两个函数,用来改变状态并执行对应回调函数。

默认创建一个Promise状态就是pendingpromise只有三种状态:pending,fulfilled,rejected,调用成功resolve和失败reject方法时,需要传递一个成功的原因/值value和失败的原因reason。每一个promise实例都有一个 then 方法。

Promise状态一经改变就不能再改变,故我们限制只能在状态为pending才改变,这样就保证状态只能改变一次。

如果抛出异常按照失败来处理。

按照以上Promise的基本要求,就有个基本结构:

const STATUS = {
  PENDING: 'PENDING',
  FULFILLED: 'FULFILLED',
  REJECTED: 'REJECTED',
}

class Promise {
  constructor(executor) {
    this.status = STATUS.PENDING
    this.value = undefined // 成功的值
    this.reason = undefined // 失败原因
    this.onResolvedCallbacks = [] // 存放成功的回调
    this.onRejectedCallbacks = [] // 存放失败的回调

    const resolve = val => {
      if (this.status === STATUS.PENDING) {
        this.status = STATUS.FULFILLED
        this.value = val
        this.onResolvedCallbacks.forEach(fn => fn())
      }
    }

    const reject = reason => {
      if (this.status === STATUS.PENDING) {
        this.status = STATUS.REJECTED
        this.reason = reason
        this.onRejectedCallbacks.forEach(fn => fn())
      }
    }

    try {
      executor(resolve, reject)
    } catch (e) {
      console.log(e)
    }
  }

  then(onFulfilled, onRejected) {
    if (this.status === STATUS.FULFILLED) {
      onFulfilled(this.value)
    }
    if (this.status === STATUS.REJECTED) {
      onRejected(this.reason)
    }
    if (this.status === STATUS.PENDING) {
      this.onResolvedCallbacks.push(() => {
        onFulfilled(this.value)
      })
      this.onRejectedCallbacks.push(() => {
        onRejected(this.reason)
      })
    }
  }
}

上面代码中,为什么不直接执行回调还要存储呢?因为如果当resolve(3)被延迟执行时,此时常理写代码来说 then 是会被执行的,但此时没有resolve,故p的状态应为pending,不应立即执行成功调用的函数,需要把它存起来,直到执行resolve再执行成功调用的函数。

let p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(3)
  }, 1000)
})
p.then(res => {
  console.log('then1', res)
  return 2
})

链式调用

链式调用想必大家多少有点了解,在 jQuery 里面的链式调用则是返回 this,而 Promise 里面的链接调用则是返回一个新的 Promise 对象。

let p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(3)
  }, 1000)
})
p.then(res => {
  console.log('then1', res) // then1 3
  return 2
}).then(res => {
  // 链式调用
  console.log('then2', res) // then2 2
})

修改then方法和增加catch方法

then(onFulfilled, onRejected) {
  let nextPromise = new Promise((resolve, reject) => {
    if (this.status === STATUS.FULFILLED) {
      setTimeout(() => {
        try {
          let res = onFulfilled(this.value)
          resolve(res)
        } catch (e) {
          reject(e)
        }
      })
    }
    if (this.status === STATUS.REJECTED) {
      setTimeout(() => {
        try {
          let res = onRejected(this.reason)
          resolve(res)
        } catch (e) {
          reject(e)
        }
      })
    }
    if (this.status === STATUS.PENDING) {
      this.onResolvedCallbacks.push(() => {
        setTimeout(() => {
          try {
            let res = onFulfilled(this.value)
            resolve(res)
          } catch (e) {
            reject(e)
          }
        })
      })
      this.onRejectedCallbacks.push(() => {
        setTimeout(() => {
          try {
            let res = onRejected(this.reason)
            resolve(res)
          } catch (e) {
            reject(e)
          }
        })
      })
    }
  })
  return nextPromise
}

catch(err) {
  // 默认没有成功,只有失败
  return this.then(undefined, err)
}

此次修改我们是在最外层包了新的Promise,然后加了个 setTimeout 模拟微任务(因为这里用的 setTimeout 模拟微任务,所以 JS 事件循环执行顺序上和原生 Promise 有区别),把回调放入,等待确保异步执行。

链式调用进阶版

上面的Promise依旧没有过关,因为如果链式调用中Promise返回的是普通值,就应该把值包装成新的Promise对象

  • 每个 then 方法都返回一个新的 Promise 对象(重点)
  • 如果 then 方法返回了一个 Promise 对象,则需要查看它的状态,如果状态是成功,则调用resolve方法,把成功的状态传递给它;如果是失败的,则把失败的状态传递给下一个Promise对象。
  • 如果 then 方法中返回的是一个原始数据类型值(如 Number、String 等)就使用此值包装成一个新的 Promise 对象返回。
  • 如果 then 方法中没有 return 语句,则返回一个用 undefined 包装的 Promise 对象
  • 如果 then 方法没有传入任何回调,则继续向下传递(值的传递特性)。
  • 如果是循环引用则需要抛出错误

修改then方法

then(onFulfilled, onRejected) {
  onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : x => x
  onRejected =
    typeof onRejected === 'function'
      ? onRejected
      : err => {
          throw err
        }
  let nextPromise = new Promise((resolve, reject) => {
    if (this.status === STATUS.FULFILLED) {
      setTimeout(() => {
        try {
          let res = onFulfilled(this.value)
          resolvePromise(res, nextPromise, resolve, reject)
        } catch (e) {
          reject(e)
        }
      })
    }

    if (this.status === STATUS.REJECTED) {
      setTimeout(() => {
        try {
          let res = onRejected(this.reason)
          resolvePromise(res, nextPromise, resolve, reject)
        } catch (e) {
          reject(e)
        }
      })
    }
    if (this.status === STATUS.PENDING) {
      this.onResolvedCallbacks.push(() => {
        setTimeout(() => {
          try {
            let res = onFulfilled(this.value)
            resolvePromise(res, nextPromise, resolve, reject)
          } catch (e) {
            reject(e)
          }
        })
      })
      this.onRejectedCallbacks.push(() => {
        setTimeout(() => {
          try {
            let res = onRejected(this.reason)
            resolve(res)
          } catch (e) {
            reject(e)
          }
        })
      })
    }
  })
  return nextPromise
}

里面增加了一个resolvePromise函数,处理调用then方法后不同返回值的情况 ,实现如下:

function resolvePromise(x, nextPromise, resolve, reject) {
  if (x === nextPromise) {
    // x 和 nextPromise 指向同一对象,循环引用抛出错误
    return reject(new TypeError('循环引用'))
  } else if (x && (typeof x === 'object' || typeof x === 'function')) {
    // x 是对象或者函数

    let called = false // 避免多次调用
    try {
      let then = x.then // 判断对象是否有 then 方法
      if (typeof then === 'function') {
        // then 是函数,就断定 x 是一个 Promise(根据Promise A+规范)
        then.call(
          x,
          function (y) {
            // 调用返回的promise,用它的结果作为下一次then的结果
            if (called) return
            called = true
            resolvePromise(y, nextPromise, resolve, reject) // 递归解析成功后的值,直到它是一个普通值为止
          },
          function (r) {
            if (called) return
            called = true
            reject(r) // 取then时发生错误了
          }
        )
      } else {
        resolve(x) // 此时 x 就是一个普通对象
      }
    } catch (e) {
      reject(e)
    }
  } else {
    // x 是原始数据类型 / 没有返回值,这里即是undefined
    resolve(x)
  }
}

Promise.resolve() 实现

class Promise {
  // ...
  static resolve(val) {
    return new Promise((resolve, reject) => {
      resolve(val)
    })
  }
}

Promise.reject() 实现

class Promise {
  // ... 
  static reject(reason) {
    return new Promise((resolve, reject) => {
      reject(reason)
    })
  }
}

Promise.prototype.finally 实现

该方法主要有两大重点

  • 无论当前这个Promise对象最终的状态是成功还是失败 ,finally方法里的回调函数都会执行一次
  • 在finally方法后面可以继续链式调用then 方法,拿到当前这个Promise对象最终返回的结果
Promise.prototype.finally = function (callback) {
  return this.then(
    data => {
      // 让函数执行,内部会调用方法,如果方法是promise需要等待它完成
      return Promise.resolve(callback()).then(() => data)
    },
    err => {
      return Promise.resolve(callback()).then(() => {
        throw err
      })
    }
  )
}

Promise.all() 实现

Promise.all可以将多个Promise实例包装成一个新的Promise实例。同时,成功和失败的返回值是不同的,成功的时候返回的是一个结果数组,而失败的时候则返回最先被reject失败状态的值。

Promise.all = function (promises) {
  if (!Array.isArray(promises)) {
    throw new Error('Not a array')
  }
  return new Promise((resolve, reject) => {
    let result = []
    let times = 0 // 计数器
    function processData(index, val) {
      result[index] = val
      if (++times === promises.length) {
        resolve(result)
      }
    }
    for (let i = 0; i < promises.length; i++) {
      let p = promises[i]
      if (isPromise(p)) {
        // Promise对象
        p.then(data => {
          processData(i, data)
        }, reject)
      } else {
        processData(i, p) // 普通值
      }
    }
  })
}

Promise.race() 实现

在执行多个异步操作中,只保留取第一个执行完成的异步操作的结果,即是哪个结果获得的快,就返回那个结果,不管结果本身是成功状态还是失败状态。其他的方法仍在执行,不过执行结果会被抛弃。

Promise.race = function (promises) {
  if (!Array.isArray(promises)) {
    throw new Error('Not a array')
  }
  return new Promise((resolve, reject) => {
    if (promises.length === 0) {
      return
    } else {
      for (let p of promises) {
        resolve(p).then(
          value => {
            resolve(value)
          },
          reason => {
            reject(reason)
          }
        )
      }
    }
  })
}

到此为止,Promise 就实现完成了。

完整源码地址


查看原文

赞 2 收藏 1 评论 0

JackySummer 发布了文章 · 9月10日

总结我做的Promise题

前言

关于 Promise 的讲解文章实在太多了,在此我就不写讲解了,直接实战检测自己对 Promise 的理解,下面是我做过的众多 Promise 题里面挑出来的 13 道题,我觉得容易错或者值得考究知识点的题,如果你想考察自己对 Promise 的掌握程度,可以做做看。

这些题是我从下面两个链接的题目又选出来的,仅作为自己的错题集/笔记题来记录,方便以后我自己检验回顾。如果你想更全面学,建议直接去做下面两篇的题。

特别鸣谢

题目 1

const promise1 = new Promise((resolve, reject) => {
  console.log('promise1')
  resolve('resolve1')
})
const promise2 = promise1.then(res => {
  console.log(res)
})
console.log('1', promise1)
console.log('2', promise2)

分析:

  • 先遇到new Promise,执行该构造函数中的代码输出 promise1
  • 遇到resolve函数, 将promise1的状态改变为fulfilled, 并将结果保存下来
  • 碰到promise1.then这个微任务,返回的是Promisepending状态,将它放入微任务队列
  • promise2是一个新的状态为pendingPromise
  • 继续执行同步代码输出promise1的状态是fulfilled,输出promise2的状态是pending
  • 宏任务执行完毕,查找微任务队列,发现promise1.then这个微任务且状态为fulfilled,执行它。

输出结果:

promise1
1 Promise{<fulfilled>: 'resolve1'}
2 Promise{<pending>}
resolve1

题目 2

const promise = new Promise((resolve, reject) => {
  console.log(1)
  setTimeout(() => {
    console.log('timerStart')
    resolve('success')
    console.log('timerEnd')
  }, 0)
  console.log(2)
})
promise.then(res => {
  console.log(res)
})
console.log(4)

分析:

  • 执行new Promsise,输出1setTimeout宏任务加到宏任务队列,继续执行同步代码输出2
  • 遇到promise.then,但其状态还是 pending,这里理解为先不执行;然后输出同步代码4
  • 一轮循环过后,进入第二次宏任务,发现延迟队列中有setTimeout定时器,执行它
  • 输出timerStart,遇到resolve,将promise的状态改为fulfilled且保存结果并将之前的promise.then推入微任务队列
  • 继续执行同步代码timerEnd
  • 宏任务全部执行完毕,查找微任务队列,发现promise.then这个微任务,执行它。

输出结果:

1
2
4
timerStart
timerEnd
success

题目 3

const promise1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('success')
  }, 1000)
})
const promise2 = promise1.then(() => {
  throw new Error('error!!!')
})
console.log('promise1', promise1)
console.log('promise2', promise2)
setTimeout(() => {
  console.log('promise1', promise1)
  console.log('promise2', promise2)
}, 2000)

分析:

  • 从上至下,先执行第一个new Promise中的函数,碰到setTimeout将它加入下一个宏任务列表
  • 跳出new Promise,碰到promise1.then这个微任务,但其状态还是为pending,这里理解为先不执行;故promise2是一个新的状态为pendingPromise
  • 执行同步代码console.log('promise1'),输出promise1的状态为pending
  • 执行同步代console.log('promise2'),输出promise2的状态为pending
  • 碰到第二个setTimeout,将其放入下一个宏任务列表
  • 第一轮宏任务执行结束,并且没有微任务需要执行,因此执行第二轮宏任务
  • 先执行第一个定时器里的内容,将promise1的状态改为fulfilled且保存结果并将之前的promise1.then推入微任务队列
  • 该定时器中没有其它的同步代码可执行,因此执行本轮的微任务队列,也就是promise1.then,它抛出了一个错误,且将promise2的状态设置为了rejected
  • 第一个定时器执行完毕,开始执行第二个定时器中的内容
  • 打印出promise1,且此时promise1的状态为fulfilled
  • 打印出promise2,且此时promise2的状态为rejected
promise1 Promise {<pending>}
promise2 Promise {<pending>}
Uncaught (in promise) Error: error!!! at <anonymous>:7:9
promise1 Promise {<fulfilled>: "success"}
promise2 Promise {<rejected>: Error: error!!! at <anonymous>:7:9}

题目 4

const promise = new Promise((resolve, reject) => {
  reject('error')
  resolve('success2')
})
promise
  .then(res => {
    console.log('then1: ', res)
  })
  .then(res => {
    console.log('then2: ', res)
  })
  .catch(err => {
    console.log('catch: ', err)
  })
  .then(res => {
    console.log('then3: ', res)
  })

分析:

  • catch不管被连接到哪里,都能捕获上层未捕捉过的错误。
  • 由于 catch()也会返回一个Promise,且由于这个Promise没有返回值,所以打印出来的是undefined

输出结果:

catch:  error
then3:  undefined

题目 5

Promise.resolve()
  .then(() => {
    return new Error('error!!!')
  })
  .then(res => {
    console.log('then: ', res)
  })
  .catch(err => {
    console.log('catch: ', err)
  })

分析:

  • Promise中,返回任意一个非promise的值都会被包裹成promise对象,例如return new Error('error!!!')会被包装为return Promise.resolve(new Error('error!!!'))
  • .then或者 .catchreturn 一个 error 对象并不会抛出错误,所以不会被后续的 .catch 捕获。

当然如果你抛出一个错误的话,可以用下面任意一种:

return Promise.reject(new Error('error!!!'))
// or
throw new Error('error!!!')

输出结果:

then:  Error: error!!!

题目 6

const promise = Promise.resolve().then(() => {
  return promise
})
promise.catch(console.err)

分析:

  • .then.catch 返回的值不能是 promise 本身,否则会造成死循环。

输出结果:

Promise {<rejected>: TypeError: Chaining cycle detected for promise #<Promise>}

题目 7

Promise.resolve(1).then(2).then(Promise.resolve(3)).then(console.log)

分析:

  • .then 或者 .catch 的参数期望是函数,传入非函数则会发生值透传。
  • 第一个then和第二个then中传入的都不是函数,一个是数字类型,一个是对象类型,因此发生了透传,将resolve(1) 的值直接传到最后一个then里。

输出结果:

1

题目 8

Promise.reject('err!!!')
  .then(
    res => {
      console.log('success', res)
    },
    err => {
      console.log('error', err)
    }
  )
  .catch(err => {
    console.log('catch', err)
  })

分析:

  • .then函数的第一个参数是用来处理Promise成功的函数,第二个则是处理失败的函数。
  • Promise.resolve('err!!!')的值会进入成功的函数,Promise.reject('err!!!')的值会进入失败的函数。
  • 如果去掉第二个参数,就会进入catch()

输出结果:

'error' 'error!!!'

题目 9

Promise.resolve('1')
  .then(res => {
    console.log(res)
  })
  .finally(() => {
    console.log('finally')
  })
Promise.resolve('2')
  .finally(() => {
    console.log('finally2')
    return '我是finally2返回的值'
  })
  .then(res => {
    console.log('finally2后面的then函数', res)
  })

分析:

  • .finally()方法不管Promise对象最后的状态如何都会执行
  • 它最终返回的默认会是一个上一次的Promise对象值,不过如果抛出的是一个异常则返回异常的Promise对象。
  • 第一行代码遇到Promise.resolve('1'),再把.then()加入微任务,这时候要注意,代码并不会接着往链式调用的下面走,也就是不会先将.finally加入微任务列表,那是因为.then本身就是一个微任务,它链式后面的内容必须得等当前这个微任务执行完才会执行,因此这里我们先不管.finally()
  • 执行Promise.resolve('2'),把.finally加入微任务队列,且链式调用后面的内容得等该任务执行完后才执行
  • 本轮宏任务执行完,执行微任务列表第一个微任务输出1,遇到.finally(),将它加入微任务列表(第三个)待执行;再执行第二个微任务输出finally2,遇到.then,把它加入到微任务(第四个)列表待执行
  • 本轮执行完两个微任务后,检索微任务列表发现还有两个微任务,故执行,输出finallyfinally2后面的then函数 2

输出结果:

1
finally2
finally
finally2后面的then函数 2

题目 10

async function async1() {
  console.log('async1 start')
  await new Promise(resolve => {
    console.log('promise1')
  })
  console.log('async1 success')
  return 'async1 end'
}
console.log('srcipt start')
async1().then(res => console.log(res))
console.log('srcipt end')

分析:

  • async1await后面的Promise是没有返回值的,也就是它的初始状态是pending状态,因此相当于一直在await
  • 所以在await之后的内容是不会执行的,也包括async1后面的.then

输出结果:

srcipt start
async1 start
promise1
srcipt end

题目 11

async function async1() {
  await async2()
  console.log('async1')
  return 'async1 success'
}
async function async2() {
  return new Promise((resolve, reject) => {
    console.log('async2')
    reject('error')
  })
}
async1().then(res => console.log(res))

分析:

  • await后面跟着的是一个状态为rejectedpromise
  • 如果在async函数中抛出了错误,则终止错误结果,不会继续向下执行。

输出结果:

async2
Uncaught (in promise) error

题目 12

var p1 = new Promise(function (resolve, reject) {
  foo.bar()
  resolve(1)
})

p1.then(
  function (value) {
    console.log('p1 then value: ' + value)
  },
  function (err) {
    console.log('p1 then err: ' + err)
  }
).then(
  function (value) {
    console.log('p1 then then value: ' + value)
  },
  function (err) {
    console.log('p1 then then err: ' + err)
  }
)

var p2 = new Promise(function (resolve, reject) {
  resolve(2)
})

p2.then(
  function (value) {
    console.log('p2 then value: ' + value)
    foo.bar()
  },
  function (err) {
    console.log('p2 then err: ' + err)
  }
)
  .then(
    function (value) {
      console.log('p2 then then value: ' + value)
    },
    function (err) {
      console.log('p2 then then err: ' + err)
      return 1
    }
  )
  .then(
    function (value) {
      console.log('p2 then then then value: ' + value)
    },
    function (err) {
      console.log('p2 then then then err: ' + err)
    }
  )

分析:

Promise 中的异常由 then 参数中第二个回调函数(Promise 执行失败的回调)处理,异常信息将作为 Promise 的值。异常一旦得到处理,then 返回的后续 Promise 对象将恢复正常,并会被 Promise 执行成功的回调函数处理。另外,需要注意 p1、p2 多级 then 的回调函数是交替执行的 ,这正是由 Promise then 回调的异步性决定的。

输出结果:

p1 then err: ReferenceError: foo is not defined
p2 then value: 2
p1 then then value: undefined
p2 then then err: ReferenceError: foo is not defined
p2 then then then value: 1

题目 13

var p1 = new Promise(function (resolve, reject) {
  resolve(Promise.resolve('resolve'))
})

var p2 = new Promise(function (resolve, reject) {
  resolve(Promise.reject('reject'))
})

var p3 = new Promise(function (resolve, reject) {
  reject(Promise.resolve('resolve'))
})

p1.then(
  function fulfilled(value) {
    console.log('fulfilled: ' + value)
  },
  function rejected(err) {
    console.log('rejected: ' + err)
  }
)

p2.then(
  function fulfilled(value) {
    console.log('fulfilled: ' + value)
  },
  function rejected(err) {
    console.log('rejected: ' + err)
  }
)

p3.then(
  function fulfilled(value) {
    console.log('fulfilled: ' + value)
  },
  function rejected(err) {
    console.log('rejected: ' + err)
  }
)

分析:

Promise 回调函数中的第一个参数 resolve,会对 Promise 执行"拆箱"动作。即当 resolve 的参数是一个 Promise 对象时,resolve 会"拆箱"获取这个 Promise 对象的状态和值,但这个过程是异步的。p1"拆箱"后,获取到 Promise 对象的状态是 resolved,因此 fulfilled 回调被执行;p2"拆箱"后,获取到 Promise 对象的状态是 rejected,因此 rejected 回调被执行。但 Promise 回调函数中的第二个参数 reject 不具备”拆箱“的能力,reject 的参数会直接传递给 then 方法中的 rejected 回调。因此,即使 p3 reject 接收了一个 resolved 状态的 Promise,then 方法中被调用的依然是 rejected,并且参数就是 reject 接收到的 Promise 对象。

输出结果:

p3 rejected: [object Promise]
p1 fulfilled: resolve
p2 rejected: reject


查看原文

赞 0 收藏 0 评论 0

JackySummer 发布了文章 · 9月3日

深入理解 JavaScript 之事件循环(Event Loop)

前言

深入理解 JavaScript 系列已经很久没更新了,之前的系列文章如下:

更多前端内容可以看我 个人博客

这次继续一步步回顾 JS 基础知识点,今天讲的是 JS 中的事件循环。

JavaScript 是单线程的

JS 是一门单线程的非阻塞的脚本语言,这表示在同一时刻最多也只有一个代码段执行。

为什么 JavaScript 是单线程的

如果 JS 是多线程的,因为 JS 有 DOM API 可以操作 DOM,如果同时开了两个线程同时操作 DOM 的话,一个线程删除了当前的 DOM 节点,另一个线程要操作当前的 DOM,那么就会有矛盾到底以哪个线程为主。为了避免这种情况出现,JS 就被设计为单线程,而且单线程执行效率高。现在虽然也有 web worker 标准的出现,但它也有很多限制,受主线程控制,是主线程的子线程。

JS 如何处理异步任务

JS 是单线程,那么非阻塞怎么体现呢?如果 JS 是阻塞的,那么 JS 发起一个异步 IO 请求,在等待结果返回的这个时间段,后面的代码就无法执行了,而 JS 主线程和渲染进程是互斥的,因此可能造成浏览器假死的状态。事实 JS 是非阻塞的,那它要怎么实现异步任务呢,靠的就是事件循环。

事件循环

事件循环就是通过异步执行任务的方法来解决单线程的弊端的。

  1. 一开始整个脚本作为一个宏任务执行
  2. 执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列
  3. 当前宏任务执行完出队,读取微任务列表,有则依次执行,直到全部执行完
  4. 执行浏览器 UI 线程的渲染工作
  5. 检查是否有 Web Worker 任务,有则执行
  6. 执行完本轮的宏任务,回到第 2 步,继续依此循环,直到宏任务和微任务队列都为空

宏任务与微任务

JS 引擎把所有任务分成两类,一类叫宏任务(macroTask),一类叫微任务(microTask)

宏任务

  • script(整体代码)
  • setTimeout/setInterval
  • I/O
  • UI 渲染
  • postMessage
  • MessageChannel
  • requestAnimationFrame
  • setImmediate(Node.js 环境)

微任务

  • new Promise().then()
  • MutaionObserver
  • process.nextTick(Node.js 环境)

经典题目 1

关于更细节的描述我就不写了,因为有更好的文章可以参考学习:

简单介绍后,接下来就来看几道经典题目:

console.log('script start')

setTimeout(function () {
  console.log('setTimeout')
}, 0)

Promise.resolve()
  .then(function () {
    console.log('promise1')
  })
  .then(function () {
    console.log('promise2')
  })

console.log('script end')
  1. 整体 script 作为第一个宏任务进入主线程,输出script start
  2. 遇到 setTimeout,setTimeout 为宏任务,加入宏任务队列
  3. 遇到 Promise,其 then 回调函数加入到微任务队列;第二个 then 回调函数也加入到微任务队列
  4. 继续往下执行,输出script end
  5. 检测微任务队列,输出promise1promise2
  6. 进入下一轮循环,执行 setTimeout 中的代码,输出setTimeout

最后执行结果为:

script start
script end
promise1
promise2
setTimeout

经典题目 2

来看一道面试的经典题目

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2')
}
console.log('script start')
setTimeout(function () {
  console.log('setTimeout')
}, 0)
async1()
new Promise(function (resolve) {
  console.log('promise1')
  resolve()
}).then(function () {
  console.log('promise2')
})
console.log('script end')
  1. 整体 script 作为第一个宏任务进入主线程,代码自上而下执行,执行同步代码,输出 script start
  2. 遇到 setTimeout,加入到宏任务队列
  3. 执行 async1(),输出async1 start;然后遇到await async2(),await 实际上是让出线程的标志,首先执行 async2(),输出async2;把 async2() 后面的代码console.log('async1 end')加入微任务队列中,跳出整个 async 函数。(async 和 await 本身就是 promise+generator 的语法糖。所以 await 后面的代码是微任务。)
  4. 继续执行,遇到 new Promise,输出promise1,把.then()之后的代码加入到微任务队列中
  5. 继续往下执行,输出script end。接着读取微任务队列,输出async1 endpromise2,执行完本轮的宏任务。继续执行下一轮宏任务的代码,输出setTimeout

最后执行结果为:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

经典题目 3

我们来看下面一段代码:

setTimeout(function () {
  console.log('timer1')
}, 0)

requestAnimationFrame(function () {
  console.log('requestAnimationFrame')
})

setTimeout(function () {
  console.log('timer2')
}, 0)

new Promise(function executor(resolve) {
  console.log('promise 1')
  resolve()
  console.log('promise 2')
}).then(function () {
  console.log('promise then')
})

console.log('end')
  • 整体 script 代码执行,开局新增三个宏任务,两个 setTimeout 和一个 requestAnimationFrame
  • 遇到 Promise,先输出promise1, promise2,加把 then 回调加入微任务队列。
  • 继续往下执行,输出end
  • 执行 promise 的 then 回调,输出promise then
  • 接下来剩三个宏任务,我们可以知道的是timer1会比timer2先执行,那么requestAnimationFrame呢?

当每一轮事件循环的微任务队列被清空后,有可能发生 UI 渲染,也就是说执行任务的耗时会影响视图渲染的时机。

通常浏览器以每秒 60 帧(60fps)的速率刷新页面,这个帧率最适合人眼交互,大概 1000ms/60 约等于 16.7ms 渲染一帧,如果要让用户看得顺畅,单个宏任务及它相应的微任务最好能在 16.7ms 内完成。

requestAnimationFrame 是什么?

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
requestAnimationFrame 的基本思想是 让页面重绘的频率和刷新频率保持同步,相比 setTimeout,requestAnimationFrame 最大的优势是由系统来决定回调函数的执行时机。

但这个也不是每轮事件循环都会执行 UI 渲染,不同浏览器有自己的优化策略,比如把几次的视图更新累积到一起重绘,重绘之前会通知 requestAnimationFrame 执行回调函数,也就是说 requestAnimationFrame 回调的执行时机是在一次或多次事件循环的 UI render 阶段。

在我的谷歌浏览器执行结果:

promise 1
promise 2
end
promise then
requestAnimationFrame
timer1
timer2

在我的火狐浏览器执行结果:

promise 1
promise 2
end
promise then
timer1
timer2
requestAnimationFrame

谷歌浏览器中的结果 requestAnimationFrame()是在一次事件循环后执行,火狐浏览器中的结果是在三次事件循环结束后执行。

可以知道,浏览器只保证 requestAnimationFrame 的回调在重绘之前执行,但没有确定的时间,何时重绘由浏览器决定。

参考文章


查看原文

赞 0 收藏 1 评论 0

JackySummer 发布了文章 · 8月27日

基于 SSR/SSG 的前端 SEO 优化

前言

前段时间对项目做了 SEO 优化,到现在才来写总结。我们知道,常规用 Vue/React 开发的是 SPA 应用,但是天然的单页面应用 SEO 就是不好,虽然说现在也有各种技术可以改善了,比如使用预渲染,但也都存在各种缺点。但是即使这样,也抵不住 Vue/React 这类框架的潮流,很多产品也可以通过其他亮点而不依赖 SEO 普及开,也有需要登录才能用的使用 SEO 也没有什么意义。

如果项目中真的对 SEO 和首屏加载速度有刚性需求,又使用 Vue/React 这类技术,且想尽量减少代码开发附加的难度,有一种比较直接的方式,就是直接使用服务端渲染的框架,Vue 的 Nuxt.js,React 的 Next.js/Gatsby。

不过,其实学习一门新框架也是一项附加成本啊哈哈,但是 SSR 渲染不过实际开发用不用,起码都要了解一下。我当前以 React 技术栈为主,所以目前只了解的是关于 React 的 SSR 渲染框架,有兴趣的可以看下我这两篇文章:

所以本文不讨论单页面应用的 SEO 优化,讲的是基于服务端渲染(SSR)/静态生成(SSG)网站 SEO 的优化。

本文会回顾传统的 SEO 的优化方式,以及基于 Gatsby SEO 的优化。

服务端渲染 SSR 与静态网站渲染 SSG

服务端是指客户端向服务器发出请求,然后运行时动态生成 html 内容并返回给客户端。
静态站点的解析是在构建时执行的,当发出请求时,html 将静态存储,直接发送回客户端。

通常来说,静态站点在运行时会更快,因为不需要服务端做处理,但缺点是对数据的任何更改都需要在服务端进行完全重建;而服务端渲染则会动态处理数据,不需要进行完全重建。

对于 Vue/React 来说,对于它们的 SSR/SSG 框架出现的原因就是主要就是 SEO 和首屏加载速度。

搜索引擎的工作原理

在搜索引擎网站的后台会有一个非常庞大的数据库,里面存储了海量的关键词,而每个关键词又对应着很多网址,这些网址是被称之为“搜索引擎蜘蛛”或“网络爬虫”程序从互联网上收集而来的。

这些"蜘蛛"在互联网上爬行,从一个链接到另一个链接,对内容进行分析,提炼关键词加入数据库中;如果蜘蛛认为是垃圾或重复信息,就舍弃继续爬行。当用户搜索时,就能检索出与关键字相关的网址显示给用户。

当用户在搜索引擎搜索时,比如搜索"前端",则跳出来所有含有"前端"二字关键字的网页,然后根据特定算法给每个含有"前端"二字的网页一个评分排名返回搜索结果。而这些包含"前端"的内容,可以是文章标题、描述、关键字、内容甚至可以是链接。当然,也有可能是广告优先置顶,你懂的。

一个关键词对用多个网址,因此就出现了排序的问题,相应的当与关键词最吻合的网址就会排在前面了。在“蜘蛛”抓取网页内容,提炼关键词的这个过程中,就存在一个问题:“蜘蛛”能否看懂。如果网站内容是 flash 和 js 等,那么它是看不懂的,会犯迷糊,即使关键字再贴切也没用。相应的,如果网站内容可以被搜索引擎能识别,那么搜索引擎就会提高该网站的权重,增加对该网站的友好度。这样一个过程我们称之为 SEO(Search Engine Optimization),即搜索引擎优化。

SEO 目的

让网站更利于各大搜索引擎抓取和收录,增加对搜索引擎的友好度,使得用户在搜索对应关键词时网站时能排在前面,增加产品的曝光率和流量。

SEO 优化方式

我们这里主要讲前端能参与和做的优化方式。比如很多 SEO 优化方式都有介绍:控制首页链接数量,扁平化目录层次,优化网站结构布局,分页导航写法这些等,但实际上,日常前端开发也充当不了网站整体设计的角色,只能是协调,这些大部分都是一开始就定好的东西。

比如新闻媒体类等网站比较重视 SEO 的,通常公司还会设有 SEO 部门或者是 SEO 优化工程师岗位,像上面说的,还有网页关键词、描述的就交给他们参与和提供,有些优化方式我们难以触及的就不细谈了,有兴趣的可以去了解。

网页 TDK 标签

  • title:当前页面的标题(强调重点即可,每个页面的 title 尽量不要相同)
  • description:当前页面的描述(列举几个关键词即可,不要过分堆积)
  • keywords:当前页面的关键词(高度概括网页内容)

每个页面的 TDK 都不一样,这个需要根据产品业务提炼出核心关键词。

那么页面的 TDK 都不一样,我们就需要对它进行动态设置,react 的话有react-helmet
插件,用于设置头部标签。

import React from 'react'
import { Helmet } from 'react-helmet'

const GoodsDetail = ({ title, description, keywords }) => {
  return (
    <div className='application'>
      <Helmet>
        <title>{title}</title>
        <meta name='description' content={`${description}`} />
        <meta name='keywords' content={`${keywords}`} />
      </Helmet>
      <div>content...</div>
    </div>
  )
}

上面是演示,实际项目做法还是会把 Helmet 里的内容单独抽离出来做组件。

在 Next.js 里面,是自带 Head 组件的:import Head from 'next/head'

语义化标签

根据内容的结构化,选择合适的 HTML5 标签尽量让代码语义化,如使用 header,footer,section,aside,article,nav 等等语义化标签可以让爬虫更好的解析。

合理使用 h1~h6 标签

一个页面中只能最多出现一次h1标签,h2标签通常作为二级标题或文章的小标题。其余h3-h6标签如要使用应按顺序层层嵌套下去,不可以断层或反序。

比如通常在首页的 logo 上加h1标签,但网站设计只展示 logo 图无文字的情况下,h1 的文字就可以设置font-size为零来隐藏

<h1>
  <img data-original="logo.png" alt="jacky" />
  <span>jacky的个人博客</span>
</h1>

图片的 alt 属性

一般来说,除非是图片仅仅是纯展示类没有任何实际信息的话,alt属性可以为空。否则使用img标签都要添加alt属性,使"蜘蛛"可以抓取到图片的信息。
当网络加载不出来或者图片地址失效时,alt属性的内容才会代替图片呈现出来,

<img data-original="dog.jpg" width="300" height="200" alt="哈士奇" />

a 标签的 title

同理,a 标签的 title 属性其实就是提示文字作用,当鼠标移动到该超链接上时,就会有提示文字的出现。通过添加该属性也有微小的作用利于 SEO。

<a
  href="https://github.com/Jacky-Summer/personal-blog"
  title="了解更多关于Jacky的个人博客"
  >了解更多</a
>

404 页面

404 页面首先是用户体验良好,不会莫名报一些其他提示。其次对蜘蛛也友好,不会因为页面错误而停止抓取,可以返回抓取网站其他页面。

nofollow 忽略跟踪

  • nofollow 有两种用法:
  1. 用于 meta 元标签,告诉爬虫该页面上所有链接都无需追踪。
<meta name="robots" content="nofollow" />
  1. 用于 a 标签,告诉爬虫该页面无需追踪。
<a href="https://www.xxxx?login" rel="nofollow">登录/注册</a>

通常用在 a 标签比较多,它主要有三个作用:

  1. "蜘蛛"分配到每个页面的权重是一定的,为了集中网页权重并将权重分给其他必要的链接,就设置rel='nofollow'告诉"蜘蛛"不要爬,来避免爬虫抓取一些无意义的页面,影响爬虫抓取的效率;而且一旦"蜘蛛"爬了外部链接,就不会再回来了。
  2. 付费链接:为了防止付费链接影响 Google 的搜索结果排名,Google 建议使用 nofollow 属性。
  3. 防止不可信的内容,最常见的是博客上的垃圾留言与评论中为了获取外链的垃圾链接,为了防止页面指向一些拉圾页面和站点。

建立 robots.txt 文件

robots.txt 文件由一条或多条规则组成。每条规则可禁止(或允许)特定抓取工具抓取相应网站中的指定文件路径。
User-agent: *
Disallow:/admin/
SiteMap: http://www.xxxx.com/sitemap.xml

关键词:

  1. User-agent 表示网页抓取工具的名称
  2. Disallow 表示不应抓取的目录或网页
  3. Allow 应抓取的目录或网页
  4. Sitemap 网站的站点地图的位置
  • User-agent: *表示对所有的搜索引擎有效
  • User-agent: Baiduspider 表示百度搜索引擎,还有谷歌 Googlebot 等等搜索引擎名称,通过这些可以设置不同搜索引擎访问的内容

参考例子的话比如百度的 robots.txt,京东的 robots.txt

robots 文件是搜索引擎访问网站时第一个访问的,然后根据文件里面设置的规则,进行网站内容的爬取。通过设置AllowDisallow访问目录和文件,引导爬虫抓取网站的信息。

它主要用于使你的网站避免收到过多请求,告诉搜索引擎应该与不应抓取哪些页面。如果你不希望网站的某些页面被抓取,这些页面可能对用户无用,就通过Disallow设置。实现定向 SEO 优化,曝光有用的链接给爬虫,将敏感无用的文件保护起来。

即使网站上面所有内容都希望被搜索引擎抓取到,也要设置一个空的 robot 文件。因为当蜘蛛抓取网站内容时,第一个抓取的文件 robot 文件,如果该文件不存在,那么蜘蛛访问时,服务器上就会有一条 404 的错误日志,多个搜索引擎抓取页面信息时,就会产生多个的 404 错误,故一般都要创建一个 robots.txt 文件到网站根目录下。

空 robots.txt 文件

User-agent: *
Disallow:

如果想要更详细的了解 robots.txt 文件,可以看下:

一般涉及目录比较多的话都会找网站工具动态生成 robots.txt,比如 生成 robots.txt

建立网站地图 sitemap

当网站刚刚上线的时候,连往该网站的外部链接并不多,爬虫可能找不到这些网页;或者该网站的网页之间没有较好的衔接关系,爬虫容易漏掉部分网页。这个时候,sitemap 就派上用场了。

sitemap 是一个将网站栏目和连接归类的一个文件,让搜索引擎全面收录站点网页地址,了解站点网页地址的权重分布以及站点内容更新情况,提高爬虫的爬取效率。Sitemap 文件包含的网址不可以超过 5 万个,且文件大小不得超过 10MB。

sitemap 地图文件包含 html(针对用户)和 xml(针对搜索引擎)两种,最常见的就是 xml 文件,XML 格式的 Sitemap 一共用到 6 个标签,其中关键标签包括链接地址(loc)、更新时间(lastmod)、更新频率(changefreq)和索引优先权(priority)。

爬虫怎么知道网站有没有提供 sitemap 文件呢,也就是上面说的路径放在了 robots.txt 里面。

先找网站的根目录里找 robots.txt,比如腾讯网下的 robots.txt 如下:

User-agent: *
Disallow:
Sitemap: http://www.qq.com/sitemap_index.xml

就找到了 sitemap 路径(只列出一部分)

<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>http://news.qq.com/news_sitemap.xml.gz</loc>
    <lastmod>2011-11-15</lastmod>
  </sitemap>
  <sitemap>
    <loc>http://finance.qq.com/news_sitemap.xml.gz</loc>
    <lastmod>2011-11-15</lastmod>
  </sitemap>
  <sitemap>
    <loc>http://sports.qq.com/news_sitemap.xml.gz</loc>
    <lastmod>2011-11-15</lastmod>
  </sitemap>
  <sitemap>
</sitemapindex>
  • loc:页面永久链接地址,可以是静态页面,也可是动态页面
  • lastmod:页面的最后修改时间,非必填项。搜索引擎根据此项与 changefreq 相结合,判断是否要重新抓取 loc 指向的内容

一般网站开发完后,这个 sitemap 一般都是靠自动生成,比如 sitemap 生成工具

结构化数据

结构化数据(Structured data)是一种标准化格式,使用它向 Google 提供有关该网页含义的明确线索,从而帮助理解该网页。一般都 JSON-LD 格式,这格式长什么样呢,看谷歌官方的示例代码:

<html>
  <head>
    <title>Party Coffee Cake</title>
    <script type="application/ld+json">
      {
        "@context": "https://schema.org/",
        "@type": "Recipe",
        "name": "Party Coffee Cake",
        "author": {
          "@type": "Person",
          "name": "Mary Stone"
        },
        "nutrition": {
          "@type": "NutritionInformation",
          "calories": "512 calories"
        },
        "datePublished": "2018-03-10",
        "description": "This coffee cake is awesome and perfect for parties.",
        "prepTime": "PT20M"
      }
    </script>
  </head>
  <body>
    <h2>Party coffee cake recipe</h2>
    <p>
      This coffee cake is awesome and perfect for parties.
    </p>
  </body>
</html>

列明了网页页面种类属于"食谱",作者和发布时间,描述和烹饪时间等等。这样谷歌搜索出来就有机会含有这些提示或者你带着关键信息去搜索更有利于找到结果。

官方提供了各种字段用来描述"食谱",你只要去查阅相关字段,就可以直接按格式来使用了。

因为该 SEO 优化针对谷歌搜索引擎特有的,所以有设置该方式的网站通常是用户是不限于国内的,不仅是结构化数据特有,还有一种 SEO 优化方式是 AMP 网页,感兴趣的可以了解看看 —— AMP

谷歌还提供了测试工具 Structured Data Testing Tool,可以输入测试网站网址来查看该网站有没有结构化数据设置。

性能优化

比如减少 http 请求,控制页面大小,懒加载,利用缓存等等,这方式就很多了,都是为了提高网站的加载速度和良好用户体验,这个也不是专指 SEO 的问题,是开发中都要做的事情。

因为当网站速度很慢时,一旦超时,"蜘蛛"也会离开。

Gatsby 下的 SEO 优化

本身 Gatsby 就采取静态生成的方式,SEO 已是可以,但依然还是要做 SEO 优化。

知道了上述的 SEO 优化方式后,Gatsby 该如何实战优化呢?这个,由于 Gatsby 社区比较强大,插件很多,所以上面几个依靠插件就可以快速配置生成。

gatsby-plugin-robots-txt

在 gatsby-config.js 里面配置

module.exports = {
  siteMetadata: {
    siteUrl: 'https://www.xxxxx.com'
  },
  plugins: ['gatsby-plugin-robots-txt']
};

gatsby-plugin-sitemap

在 gatsby-config.js 里面配置

{
  resolve: `gatsby-plugin-sitemap`,
  options: {
    sitemapSize: 5000,
  },
},

网页 TDK

Gatsby 标准脚手架和有官方文档都有一个 SEO.js 文件,里面就是给我们设置 TDK 提供了方法

import React from 'react'
import PropTypes from 'prop-types'
import { Helmet } from 'react-helmet'
import { useStaticQuery, graphql } from 'gatsby'

function SEO({ description, lang, meta, title }) {
  const { site } = useStaticQuery(
    graphql`
      query {
        site {
          siteMetadata {
            title
            description
            author
          }
        }
      }
    `
  )

  const metaDescription = description || site.siteMetadata.description

  return (
    <Helmet
      htmlAttributes={{
        lang,
      }}
      title={title}
      meta={[
        {
          name: `description`,
          content: metaDescription,
        },
        {
          property: `og:title`,
          content: title,
        },
        {
          property: `og:description`,
          content: metaDescription,
        },
        {
          property: `og:type`,
          content: `website`,
        },
        {
          name: `twitter:card`,
          content: `summary`,
        },
        {
          name: `twitter:creator`,
          content: site.siteMetadata.author,
        },
        {
          name: `twitter:title`,
          content: title,
        },
        {
          name: `twitter:description`,
          content: metaDescription,
        },
      ].concat(meta)}
    />
  )
}

SEO.defaultProps = {
  lang: `en`,
  meta: [],
  description: ``,
}

SEO.propTypes = {
  description: PropTypes.string,
  lang: PropTypes.string,
  meta: PropTypes.arrayOf(PropTypes.object),
  title: PropTypes.string.isRequired,
}

export default SEO

然后在页面模板文件中引入 SEO.js,并传入页面的变量参数,即可设置 TDK 等等头部信息。

structured data

比如说项目是新闻文章展示的,可以设置三种结构化数据(数据类型和字段不是凭空捏造的,这个需要去 Google 查符合对应匹配的)——文章详情页,文章列表页,再加个公司的介绍。

在项目根目录下新建

  • ./src/components/Jsonld.js
    封装外部包住的 script 标签作为组件
import React from 'react'
import { Helmet } from 'react-helmet'

function JsonLd({ children }) {
  return (
    <Helmet>
      <script type="application/ld+json">{JSON.stringify(children)}</script>
    </Helmet>
  )
}

export default JsonLd
  • ./src/utils/json-ld/article.js - 文章详情结构化数据描述
const articleSchema = ({
  url,
  headline,
  image,
  datePublished,
  dateModified,
  author,
  publisher,
}) => ({
  '@context': 'http://schema.org',
  '@type': 'Article',
  mainEntityOfPage: {
    '@type': 'WebPage',
    '@id': url,
  },
  headline,
  image,
  datePublished,
  dateModified,
  author: {
    '@type': 'Person',
    name: author,
  },
  publisher: {
    '@type': 'Organization',
    name: publisher.name,
    logo: {
      '@type': 'ImageObject',
      url: publisher.logo,
    },
  },
})

export default articleSchema
  • ./src/utils/json-ld/item-list.js - 文章列表结构化数据描述
const itemListSchema = ({ itemListElement }) => ({
  '@context': 'http://schema.org',
  '@type': 'ItemList',
  itemListElement: itemListElement.map((item, index) => ({
    '@type': 'ListItem',
    position: index + 1,
    ...item,
  })),
})

export default itemListSchema
  • ./src/utils/json-ld/organization.js - 公司组织结构化数据描述
const organizationSchema = ({ name, url }) => ({
  '@context': 'http://schema.org',
  '@type': 'Organization',
  name,
  url,
})

export default organizationSchema

然后再分别引入页面,比如我们在文章详情页面,引入对应类型文件,大概就是这么个用法:

// ...
import JsonLd from '@components/JsonLd'
import SEO from '@components/SEO'
import articleSchema from '@utils/json-ld/article'

const DetailPage = ({ data }) => {
  // 处理 data,拆开相关字段
  return (
    <Layout>
      <SEO
        title={meta_title || title}
        description={meta_description}
        keywords={meta_keywords}
      />
      <JsonLd>
        {articleSchema({
          url,
          headline: title,
          datePublished: first_publication_date,
          dateModified: last_publication_date,
          author: siteMetadata.title,
          publisher: {
            name: siteMetadata.title,
            logo: 'xxx',
          },
        })}
      </JsonLd>
      <Container>
        <div>content...</div>
      </Container>
    </Layout>
  )
}

上面的代码要是迷迷糊糊倒是正常,因为没有了解过结构化数据的内容,但看文档就大概可以了解清楚了。

Lighthouse 性能优化工具

可以去谷歌商店安装LightHouse,打开 F12,进入你的网站,点击Generate report,就会生成网站对应的报告

  • 生成的 report:

在它下面有一些提示,针对性能和 SEO 等,你可以根据提示去改善你的代码。

文章的介绍到这里就结束了,希望对大家了解 SEO 有一点帮助。SEO 的摸索并不是以上举例完就差不多没了,其实有各种各样的方式可以优化。上面列举的是比较常见的,事实上,我觉得 SEO 优化无非是想吸引更多的用户点击和使用网站,但如果网站的内容优质用户体验良好,加性能好,那么有用户使用后就自带推广性,那么无疑比 SEO 简单的优化强多了。

参考文章


查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 53 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2019-07-18
个人主页被 1.1k 人浏览