GithubHelp home page GithubHelp logo

pingan8787 / leo-javascript Goto Github PK

View Code? Open in Web Editor NEW
961.0 961.0 162.0 271.25 MB

欢迎关注公众号“前端自习课”,本仓库包含丰富的前端学习资料,包括 JavaScript、前端框架、HTTP、GraphQL、TS、Vue、React、Webpack等,还有很多我的原创文章,喜欢的朋友欢迎stat。:rocket:持续更新中...

Home Page: https://www.yuque.com/wangpingan

HTML 17.76% JavaScript 70.75% TypeScript 5.62% CSS 4.28% Python 0.24% SCSS 0.08% Vue 1.28%
blog http hybrid javascript react typescript vue webpack

leo-javascript's Introduction

各位朋友好,我是王平安(英文名Chris),来自东亚文化之都 - 泉州,也是千万前端开发工程师中的一枚小小开发仔。相信技术改变世界。

About the author 👋

博客 语雀 知乎 掘金 思否 CSDN 简书

完整知识库,请查看我的💌【语雀知识库】,阅读体验更好。

pingan8787's github stats

leo-javascript's People

Contributors

pingan8787 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

leo-javascript's Issues

【原理】200 行 JS 代码实现 JS 代码编译器(人人都能学会)


最近看到掘金、前端公众号好多 ES2020 的文章,想说一句:放开我,我还学得动!


先问大家一句,日常项目开发中你能离开 ES6 吗?

一、前言

对于前端同学来说,编译器可能适合神奇的魔盒🎁,表面普通,但常常给我们惊喜。
编译器,顾名思义,用来编译,编译什么呢?当然是编译代码咯🌹。



其实我们也经常接触到编译器的使用场景:

  • React 中 JSX 转换成 JS 代码;
  • 通过 Babel 将 ES6 及以上规范的代码转换成 ES5 代码;
  • 通过各种 Loader 将 Less / Scss 代码转换成浏览器支持的 CSS 代码;
  • 将 TypeScript 转换为 JavaScript 代码。
  • and so on...


使用场景非常之多,我的双手都数不过来了。😄
虽然现在社区已经有非常多工具能为我们完成上述工作,但了解一些编译原理是很有必要的。接下来进入本文主题:200行JS代码,带你实现代码编译器

二、编译器介绍

2.1 程序运行方式

现代程序主要有两种编译模式:静态编译和动态解释。推荐一篇文章《Angular 2 JIT vs AOT》介绍得非常详细。

静态编译

简称 AOT(Ahead-Of-Time)即 提前编译 ,静态编译的程序会在执行前,会使用指定编译器,将全部代码编译成机器码。

(图片来自:https://segmentfault.com/a/1190000008739157


在 Angular 的 AOT 编译模式开发流程如下:

  • 使用 TypeScript 开发 Angular 应用
  • 运行 ngc 编译应用程序
    • 使用 Angular Compiler 编译模板,一般输出 TypeScript 代码
    • 运行 tsc 编译 TypeScript 代码
  • 使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等
  • 部署应用

动态解释

简称 JIT(Just-In-Time)即 即时编译 ,动态解释的程序会使用指定解释器,一边编译一边执行程序。
(图片来自:https://segmentfault.com/a/1190000008739157


在 Angular 的 JIT 编译模式开发流程如下:

  • 使用 TypeScript 开发 Angular 应用
  • 运行 tsc 编译 TypeScript 代码
  • 使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等
  • 部署应用

AOT vs JIT

AOT 编译流程:(图片来自:https://segmentfault.com/a/1190000008739157

JIT 编译流程:(图片来自:https://segmentfault.com/a/1190000008739157

特性 AOT JIT
编译平台 (Server) 服务器 (Browser) 浏览器
编译时机 Build (构建阶段) Runtime (运行时)
包大小 较小 较大
执行性能 更好 -
启动时间 更短 -

除此之外 AOT 还有以下优点:

  • 在客户端我们不需要导入体积庞大的 angular 编译器,这样可以减少我们 JS 脚本库的大小
  • 使用 AOT 编译后的应用,不再包含任何 HTML 片段,取而代之的是编译生成的 TypeScript 代码,这样的话 TypeScript 编译器就能提前发现错误。总而言之,采用 AOT 编译模式,我们的模板是类型安全的。

2.2 现代编译器工作流程

摘抄维基百科中对 编译器工作流程介绍:

一个现代编译器的主要工作流程如下:
源代码(source code)→ 预处理器(preprocessor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标代码(object code)→ 链接器(linker)→ 可执行文件(executables),最后打包好的文件就可以给电脑去判读运行了。

这里更强调了编译器的作用:将原始程序作为输入,翻译产生目标语言的等价程序

编译器三个核心阶段.png

目前绝大多数现代编译器工作流程基本类似,包括三个核心阶段:

  1. 解析(Parsing :通过词法分析和语法分析,将原始代码字符串解析成抽象语法树(Abstract Syntax Tree)
  2. 转换(Transformation:对抽象语法树进行转换处理操作;
  3. 生成代码(Code Generation:将转换之后的 AST 对象生成目标语言代码字符串。

三、编译器实现

本文将通过 The Super Tiny Compiler 源码解读,学习如何实现一个轻量编译器,最终实现将下面原始代码字符串(Lisp 风格的函数调用)编译成 JavaScript 可执行的代码

Lisp 风格(编译前) JavaScript 风格(编译后)
2 + 2 (add 2 2) add(2, 2)
4 - 2 (subtract 4 2) subtract(4, 2)
2 + (4 - 2) (add 2 (subtract 4 2)) add(2, subtract(4, 2))


话说 The Super Tiny Compiler 号称可能是有史以来最小的编译器,并且其作者 James Kyle 也是 Babel 活跃维护者之一。


让我们开始吧~

3.1 The Super Tiny Compiler 工作流程

现在对照前面编译器的三个核心阶段,了解下 The Super Tiny Compiler  编译器核心工作流程:
The Super Tiny Compiler编译器工作流程.png

图中详细流程如下:

  1. 执行入口函数,输入原始代码字符串作为参数;
// 原始代码字符串
(add 2 (subtract 4 2))
  1. 进入解析阶段(Parsing),原始代码字符串通过词法分析器(Tokenizer)转换为词法单元数组,然后再通过 语法分析器(Parser)将词法单元数组转换为抽象语法树(Abstract Syntax Tree 简称 AST),并返回;

解析阶段 - 词法分析.png

解析阶段 - 语法分析.png

  1. 进入转换阶段(Transformation),将上一步生成的 AST 对象 导入转换器(Transformer),通过转换器中的遍历器(Traverser),将代码转换为我们所需的新的 AST 对象

转换阶段.png

  1. 进入代码生成阶段(Code Generation),将上一步返回的新 AST 对象通过代码生成器(CodeGenerator),转换成 JavaScript Code

代码生成阶段.png

  1. 代码编译结束,返回 JavaScript Code




上述流程看完后可能一脸懵逼,不过没事,请保持头脑清醒,先有个整个流程的印象,接下来我们开始阅读代码:

3.2 入口方法

首先定义一个入口方法 compiler ,接收原始代码字符串作为参数,返回最终 JavaScript Code:

// 编译器入口方法 参数:原始代码字符串 input
function compiler(input) {
  let tokens = tokenizer(input);
  let ast    = parser(tokens);
  let newAst = transformer(ast);
  let output = codeGenerator(newAst);
  return output;
}

3.3 解析阶段

在解析阶段中,我们定义词法分析器方法 tokenizer  和语法分析器方法 parser 然后分别实现:

// 词法分析器 参数:原始代码字符串 input
function tokenizer(input) {};

// 语法分析器 参数:词法单元数组tokens
function parser(tokens) {};

词法分析器

词法分析器方法 tokenizer 的主要任务:遍历整个原始代码字符串,将原始代码字符串转换为词法单元数组(tokens),并返回。
在遍历过程中,匹配每种字符并处理成词法单元压入词法单元数组,如当匹配到左括号( ( )时,将往词法单元数组(tokens)压入一个词法单元对象{type: 'paren', value:'('})。
词法分析器工作流程.png

// 词法分析器 参数:原始代码字符串 input
function tokenizer(input) {
  let current = 0;  // 当前解析的字符索引,作为游标
  let tokens = [];  // 初始化词法单元数组
  // 循环遍历原始代码字符串,读取词法单元数组
  while (current < input.length) {
    let char = input[current];
    // 匹配左括号,匹配成功则压入对象 {type: 'paren', value:'('}
    if (char === '(') {
      tokens.push({
        type: 'paren',
        value: '('
      });
      current++;
      continue; // 自增current,完成本次循环,进入下一个循环
    }
    // 匹配右括号,匹配成功则压入对象 {type: 'paren', value:')'}
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')'
      });
      current++;
      continue;
    }
    
    // 匹配空白字符,匹配成功则跳过
    // 使用 \s 匹配,包括空格、制表符、换页符、换行符、垂直制表符等
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }
    // 匹配数字字符,使用 [0-9]:匹配
    // 匹配成功则压入{type: 'number', value: value}
    // 如 (add 123 456) 中 123 和 456 为两个数值词法单元
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {
      let value = '';
      // 匹配连续数字,作为数值
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'number', value });
      continue;
    }
    // 匹配形双引号包围的字符串
    // 匹配成功则压入 { type: 'string', value: value }
    // 如 (concat "foo" "bar") 中 "foo" 和 "bar" 为两个字符串词法单元
    if (char === '"') {
      let value = '';
      char = input[++current]; // 跳过左双引号
      // 获取两个双引号之间所有字符
      while (char !== '"') {
        value += char;
        char = input[++current];
      }
      char = input[++current];// 跳过右双引号
      tokens.push({ type: 'string', value });
      continue;
    }
    // 匹配函数名,要求只含大小写字母,使用 [a-z] 匹配 i 模式
    // 匹配成功则压入 { type: 'name', value: value }
    // 如 (add 2 4) 中 add 为一个名称词法单元
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';
      // 获取连续字符
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'name', value });
      continue;
    }
    // 当遇到无法识别的字符,抛出错误提示,并退出
    throw new TypeError('I dont know what this character is: ' + char);
  }
  // 词法分析器的最后返回词法单元数组
  return tokens;
}

语法分析器

语法分析器方法 parser 的主要任务:将词法分析器返回的词法单元数组,转换为能够描述语法成分及其关系的中间形式(抽象语法树 AST)。
语法分析器工作流程.png

// 语法分析器 参数:词法单元数组tokens
function parser(tokens) {
  let current = 0; // 设置当前解析的词法单元的索引,作为游标
  // 递归遍历(因为函数调用允许嵌套),将词法单元转成 LISP 的 AST 节点
  function walk() {
    // 获取当前索引下的词法单元 token
    let token = tokens[current]; 

    // 数值类型词法单元
    if (token.type === 'number') {
      current++; // 自增当前 current 值
      // 生成一个 AST节点 'NumberLiteral',表示数值字面量
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }

    // 字符串类型词法单元
    if (token.type === 'string') {
      current++;
      // 生成一个 AST节点 'StringLiteral',表示字符串字面量
      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }

    // 函数类型词法单元
    if (token.type === 'paren' && token.value === '(') {
      // 跳过左括号,获取下一个词法单元作为函数名
      token = tokens[++current];

      let node = {
        type: 'CallExpression',
        name: token.value,
        params: []
      };

      // 再次自增 current 变量,获取参数词法单元
      token = tokens[++current];

      // 遍历每个词法单元,获取函数参数,直到出现右括号")"
      while ((token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')) {
        node.params.push(walk());
        token = tokens[current];
      }

      current++; // 跳过右括号
      return node;
    }
    // 无法识别的字符,抛出错误提示
    throw new TypeError(token.type);
  }

  // 初始化 AST 根节点
  let ast = {
    type: 'Program',
    body: [],
  };

  // 循环填充 ast.body
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // 最后返回ast
  return ast;
}

3.4 转换阶段

在转换阶段中,定义了转换器 transformer 函数,使用词法分析器返回的 LISP 的 AST 对象作为参数,将 AST 对象转换成一个新的 AST 对象。


为了方便代码组织,我们定义一个遍历器 traverser 方法,用来处理每一个节点的操作。

// 遍历器 参数:ast 和 visitor
function traverser(ast, visitor) {
  // 定义方法 traverseArray 
  // 用于遍历 AST节点数组,对数组中每个元素调用 traverseNode 方法。
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }

  // 定义方法 traverseNode
  // 用于处理每个 AST 节点,接受一个 node 和它的父节点 parent 作为参数
  function traverseNode(node, parent) {
    // 获取 visitor 上对应方法的对象
    let methods = visitor[node.type];
    // 获取 visitor 的 enter 方法,处理操作当前 node
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    switch (node.type) {
      // 根节点
      case 'Program':
        traverseArray(node.body, node);
        break;
      // 函数调用
      case 'CallExpression':
        traverseArray(node.params, node);
        break;
      // 数值和字符串,忽略
      case 'NumberLiteral':
      case 'StringLiteral':
        break;

      // 当遇到无法识别的字符,抛出错误提示,并退出
      default:
        throw new TypeError(node.type);
    }
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }
  // 首次执行,开始遍历
  traverseNode(ast, null);
}

在看遍历器 traverser 方法时,建议结合下面介绍的转换器 transformer 方法阅读:

// 转化器,参数:ast
function transformer(ast) {
  // 创建 newAST,与之前 AST 类似,Program:作为新 AST 的根节点
  let newAst = {
    type: 'Program',
    body: [],
  };

  // 通过 _context 维护新旧 AST,注意 _context 是一个引用,从旧的 AST 到新的 AST。
  ast._context = newAst.body;

  // 通过遍历器遍历 处理旧的 AST
  traverser(ast, {
    // 数值,直接原样插入新AST,类型名称 NumberLiteral
    NumberLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },
    // 字符串,直接原样插入新AST,类型名称 StringLiteral
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },
    // 函数调用
    CallExpression: {
      enter(node, parent) {
        // 创建不同的AST节点
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };

        // 函数调用有子类,建立节点对应关系,供子节点使用
        node._context = expression.arguments;

        // 顶层函数调用算是语句,包装成特殊的AST节点
        if (parent.type !== 'CallExpression') {

          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }
        parent._context.push(expression);
      },
    }
  });
  return newAst;
}

重要一点,这里通过 _context 引用来维护新旧 AST 对象,管理方便,避免污染旧 AST 对象。

3.5 代码生成

接下来到了最后一步,我们定义代码生成器 codeGenerator 方法,通过递归,将新的 AST 对象代码转换成 JavaScript 可执行代码字符串。

// 代码生成器 参数:新 AST 对象
function codeGenerator(node) {

  switch (node.type) {
    // 遍历 body 属性中的节点,且递归调用 codeGenerator,按行输出结果
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // 表达式,处理表达式内容,并用分号结尾
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';'
      );

    // 函数调用,添加左右括号,参数用逗号隔开
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // 标识符,返回其 name
    case 'Identifier':
      return node.name;
    // 数值,返回其 value
    case 'NumberLiteral':
      return node.value;

    // 字符串,用双引号包裹再输出
    case 'StringLiteral':
      return '"' + node.value + '"';

    // 当遇到无法识别的字符,抛出错误提示,并退出
    default:
      throw new TypeError(node.type);
  }
}

3.6 编译器测试

截止上一步,我们完成简易编译器的代码开发。接下来通过前面原始需求的代码,测试编译器效果如何:

const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const source = "(add 2 (subtract 4 2))";
const target = compiler(source); // "add(2, (subtract(4, 2));"

const result = eval(target); // Ok result is 4

3.7 工作流程小结

总结 The Super Tiny Compiler 编译器整个工作流程:
1、input => tokenizer => tokens
2、tokens => parser => ast
3、ast => transformer => newAst
4、newAst => generator => output

其实多数编译器的工作流程都大致相同:
The Super Tiny Compiler编译器工作流程(方法实现).png

四、手写 Webpack 编译器

根据之前介绍的 The Super Tiny Compiler编译器核心工作流程,再来手写 Webpack 的编译器,会让你有种众享丝滑的感觉~


话说,有些面试官喜欢问这个呢。当然,手写一遍能让我们更了解 Webpack 的构建流程,这个章节我们简要介绍一下。

4.1 Webpack 构建流程分析

从启动构建到输出结果一系列过程:

  1. 初始化参数

解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。

  1. 开始编译

上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。

  1. 确定入口

从配置的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。

  1. 编译模块

递归中根据文件类型loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

  1. 完成模块编译并输出

递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk

  1. 输出完成

输出所有的 chunk 到文件系统。


注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩覆盖之前的结果
Webpack构建流程.png

4.2 代码实现

手写 Webpack 需要实现以下三个核心方法:

  • createAssets : 收集和处理文件的代码;
  • createGraph :根据入口文件,返回所有文件依赖图;
  • bundle : 根据依赖图整个代码并输出;

1. createAssets

function createAssets(filename){
    const content = fs.readFileSync(filename, "utf-8"); // 根据文件名读取文件内容
  
  	// 将读取到的代码内容,转换为 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源码类型
    })
    const dependencies = []; // 用于收集文件依赖的路径

  	// 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });

  	// 通过 AST 将 ES6 代码转换成 ES5 代码
    const { code } = babel.transformFromAstSync(ast, null, {
        presets: ["@babel/preset-env"]
    });

    let id = moduleId++;
    return {
        id,
        filename,
        code,
        dependencies
    }
}

2. createGraph

function createGraph(entry) {
    const mainAsset = createAssets(entry); // 获取入口文件下的内容
    const queue = [mainAsset];
    for(const asset of queue){
        const dirname = path.dirname(asset.filename);
        asset.mapping = {};
        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径
            const child = createAssets(absolutePath);
            asset.mapping[relativePath] = child.id;
            queue.push(child); // 递归去遍历所有子节点的文件
        })
    }
    return queue;
}

3. bunlde

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports: {}
                }

                fn(localRequire, module, module.exports);

                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

五、总结

本文从编译器概念和基本工作流程开始介绍,然后通过 The Super Tiny Compiler 译器源码,详细介绍核心工作流程实现,包括词法分析器语法分析器遍历器转换器的基本实现,最后通过代码生成器,将各个阶段代码结合起来,实现了这个号称可能是有史以来最小的编译器。
本文也简要介绍了手写 Webpack 的实现,需要读者自行完善和深入哟!
是不是觉得很神奇~



当然通过本文学习,也仅仅是编译器相关知识的边山一脚,要学的知识还有非常多,不过好的开头,更能促进我们学习动力。加油!


最后,文中介绍到的代码,我存放在 Github 上:

  1. [learning]the-super-tiny-compiler.js
  2. [writing]webpack-compiler.js

六、参考资料

  1. 《The Super Tiny Compiler》
  2. 《有史以来最小的编译器源码解析》
  3. 《Angular 2 JIT vs AOT》

关于我

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推荐 https://github.com/pingan8787/Leo_Reading/issues
ES小册 js.pingan8787.com

【基础】初中级前端 JavaScript 自测清单 - 1

最近原创文章🔥:


前言

最近与部门老大一起面试了许多前端求职者,其中想换个学习氛围较好的人占多数,但良好的学习氛围也是需要一点点营造出来的🌺。

为此我们组建了我们团队内部的“现代 JavaScript 突击队”,第一期学习内容为《现代 JavaScript 教程》系列,帮助小组成员系统地进行学习巩固,并让大家养成系统性学习和输出学习总结的学习方式

本文作为我输出的第一部分学习总结,希望作为一份自测清单,帮助大家巩固知识,温故知新。

这里也下面分享我们学习小组的“押金制度”和“押金记录表”🍀

“押金制度”和“押金记录表”

接下来开始分享自测清单的内容。

初中级前端 JavaScript 自测清单.png

一、Hello World!

1. 脚本引入方式

JavaScript 脚本引入方式有两种:

  • <script>  标签插入脚本;
  • <script> 标签 src 设置脚本地址。

2. script 标签属性

<script>  标签有以下常用属性:

2.1 src

src :指定外部脚本的URI, 如果设置了 src 特性,script 标签内容将会被忽略;

<script src="example-url.js"></script>

2.2 type

type :指定引用脚本的语言,属性值为 MIME 类型,包括text/javascript, text/ecmascript, application/javascript, 和application/ecmascript。如果没有定义这个属性,脚本会被视作JavaScript。

ES6 新增了属性值 module ,代码会被当做 JavaScript 模块。

<script type="text/javascript"></script>

2.3 async

async 规定一旦脚本可用,则会异步执行。
注意:async 属性仅适用于外部脚本(只有在使用 src 属性时)。
有多种执行外部脚本的方法:
如果 async="async" :脚本相对于页面的其余部分异步地执行(当页面继续进行解析时,脚本将被执行);
如果不使用 async  且 defer="defer" :脚本将在页面完成解析时执行;
如果既不使用 async 也不使用 defer :在浏览器继续解析页面之前,立即读取并执行脚本;

<script async="async"></script>

2.4 defer

defer 属性规定是否对脚本执行进行延迟,直到页面加载为止。

如果您的脚本不会改变文档的内容,可将 defer 属性加入到 <script> 标签中,以便加快处理文档的速度。因为浏览器知道它将能够安全地读取文档的剩余部分而不用执行脚本,它将推迟对脚本的解释,直到文档已经显示给用户为止。

<script defer="defer"></script>

详细介绍可以阅读《MDN <script> 章节 》

二、代码结构

1. 语句

语句是执行行为(action)的语法结构和命令。如: alert('Hello, world!') 这样可以用来显示消息的语句。

2. 分号

存在分行符时,多数情况下可以省略分号。但不全是,比如:

alert(3 +
1
+ 2);

建议新人最好不要省略分号。

3. 注释

单行注释以两个正斜杠字符 // 开始。

// 注释文本
console.log("leo");

多行注释以一个正斜杠和星号开始 “/*” 并以一个星号和正斜杆结束 “*/”

/*
这是多行注释。
第二行注释。
*/
console.log("leo");

三、现代模式,"use strict"

1. 作用

JavaScript 的严格模式是使用受限制的 JavaScript 的一种方式,从而隐式地退出“草率模式”。

"use strict" 指令将浏览器引擎转换为“现代”模式,改变一些内建特性的行为。

2. 使用

通过在脚本文件/函数开头添加 "use strict"; 声明,即可启用严格模式。
全局开启严格模式:

// index.js
"use strict";
const v = "Hi!  I'm a strict mode script!";

函数内开启严格模式:

// index.js
function strict() {
  'use strict';
  function nested() { 
    return "And so am I!"; 
  }
  return "Hi!  I'm a strict mode function!  " + nested();
}

3. 注意点

  1. "use strict" 需要定义在脚本最顶部(函数内除外),否则严格模式可能无法启用。
  2. 一旦进入了严格模式,就无法关闭严格模式。

4. 体验

启用 "use strict" 后,为未定义元素赋值将抛出异常:

"use strict";
leo = 17; // Uncaught ReferenceError: leo is not defined

启用 "use strict" 后,试图删除不可删除的属性时会抛出异常:

"use strict";
delete Object.prototype; // Uncaught TypeError: Cannot delete property 'prototype' of function Object() { [native code] }

详细介绍可以阅读《MDN 严格模式章节 》

四、变量

1. 介绍

变量是数据的“命名存储”。

2. 使用

目前定义变量可以使用三种关键字:var / let / const。三者区别可以阅读《let 和 const 命令》

let name = "leo";
let name = "leo", age, addr;
let name = "leo", age = 27, addr = "fujian";

3. 命名建议

变量命名有 2 个限制:

  1. 变量名称必须仅包含字母,数字,符号 $_
  2. 首字符必须非数字
    变量命名还有一些建议:
  • 常量一般用全大写,如 const PI = 3.141592
  • 使用易读的命名,比如 userName 或者 shoppingCart

4. 注意点

  • JavaScript 变量名称区分大小写,如变量 leoLeo 是不同的;
  • JavaScript 变量名称允许非英文字母,但不推荐,如 let 平安 = "leo"
  • 避免使用  abc 这种缩写。

五、数据类型

JavaScript 是一种弱类型或者说动态语言。这意味着你不用提前声明变量的类型,在程序运行过程中,类型会被自动确定。这也意味着你可以使用同一个变量保存不同类型的数据:

var foo = 42;    // foo is a Number now
foo = "bar"; // foo is a String now
foo = true;  // foo is a Boolean now

详细介绍可以阅读《MDN JavaScript 数据类型和数据结构 》

1. 八大数据类型

前七种为基本数据类型,也称为原始类型(值本身无法被改变),而 object 为复杂数据类型。
八大数据类型分别是:

  • number 用于任何类型的数字:整数或浮点数,在 ±2 范围内的整数。
  • bigint 用于任意长度的整数。
  • string 用于字符串:一个字符串可以包含一个或多个字符,所以没有单独的单字符类型。
  • boolean 用于 truefalse
  • null 用于未知的值 —— 只有一个 null 值的独立类型。
  • undefined 用于未定义的值 —— 只有一个 undefined 值的独立类型。
  • symbol 用于唯一的标识符。
  • object 用于更复杂的数据结构。
    每个类型后面会详细介绍。

2. 检测数据类型

通过 typeof 运算符检查:

  • 两种形式:typeof x 或者 typeof(x)
  • 以字符串的形式返回类型名称,例如 "string"
  • typeof null 会返回 "object" —— 这是 JavaScript 编程语言的一个错误,实际上它并不是一个 object
typeof "leo" // "string"
typeof undefined    // "undefined"
typeof 0     // "number"
typeof NaN   // "number"
typeof 10n   // "bigint"
typeof true  // "boolean"
typeof Symbol("id") // "symbol"
typeof [1,2,3,4]    // "object"
typeof Math  // "object"  (1) Math 是一个提供数学运算的内建 object。
typeof null  // "object"  (2) JavaScript 语言的一个错误,null 不是一个 object。null 有自己的类型,它是一个特殊值。
typeof alert // "function"  (3) alert 在 JavaScript 语言中是一个函数。

六、类型转换

JavaScript 变量可以转换为新变量或其他数据类型:

  • 通过使用 JavaScript 函数
  • 通过 JavaScript 自身自动转换

1. 字符串转换

通过全局方法 String()  将**其他类型数据(任何类型的数字,字母,布尔值,对象)**转换为 String 类型:

String(123);   // "123"
// Number方法toString()/toExponential()/toFixed()/toPrecision() 也有同样效果。
String(false); // "false"
// Boolean方法 toString() 也有同样效果。
String(new Date()); // "Sun Jun 07 2020 21:44:20 GMT+0800 (**标准时间)"
// Date方法 toString() 也有同样效果。
String(leo);

2. 数值转换

通过以下几种方式能将其他类型数据转换为 Number 类型:

  • 一元运算符 +
const age = +"22"; // 22
  • Number 方法
const age = Number("22"); // 22
Number.parseFloat("22");  // 22
Number.parseInt("22");  // 22
  • 其他方式转 Number 类型
// 布尔值
Number(false)     // 返回 0
Number(true)      // 返回 1
// 日期
const date = new Date();
Number(date);     // 返回 1591537858154
date.getTime();   // 返回 1591537858154,效果一致。
// 自动转换
5 + null    // 返回 5         null 转换为 0
"5" + null  // 返回"5null"   null 转换为 "null"
"5" + 1     // 返回 "51"      1 转换为 "1" 
"5" - 1     // 返回 4         "5" 转换为 5

3. 布尔值转换

转换规则如下:

  • 直观上为“空”的值(如 0、空字符串、nullundefinedNaN)将变为 false
  • 其他值变成 true
Boolean(1); // true
Boolean(0); // false
Boolean("hello"); // true
Boolean(""); // false
Boolean("0"); // true
Boolean(" "); // 空白, 也是 true (任何非空字符串是 true)

4. 小结

类型转换

七、运算符

1、运算符概念

常见运算符如加法 + 、减法 - 、乘法 * 和除法 / ,举一个例子,来介绍一些概念:

let sum = 1 + 2;
let age = +18;

其中:

  • 加法运算 1 + 2 中, 12 为 2 个运算元,左运算元 1 和右运算元 2 ,即运算元就是运算符作用的对象。
  • 1 + 2 运算式中包含 2 个运算元,因此也称该运算式中的加号  +二元运算符。
  • +18 中的加号 + 对应只有一个运算元,则它是 一元运算符

2、+ 号运算符

let msg = "hello " + "leo"; // "hello leo"
let total = 10 + 20;  // 30
let text1 = "1" + "2"; // "12"
let text2 = "1" + 2;   // "12"
let text3 = 1 + "2";   // "12"
let text4 = 1 + 2 + "3";  // "33"
let num = +text1; //  12 转换为 Number 类型

3、运算符优先级

运算符的优先级决定了表达式中运算执行的先后顺序,优先级高的运算符最先被执行。
下面的表将所有运算符按照优先级的不同从高(20)到低(1)排列。

优先级 运算类型 关联性 运算符
20 圆括号 n/a(不相关) ( … )
19 成员访问 从左到右 … . …
需计算的成员访问 从左到右 … [ … ]
new (带参数列表) n/a new … ( … )
函数调用 从左到右 … ( … )
可选链(Optional chaining) 从左到右 ?.
18 new (无参数列表) 从右到左 new …
17 后置递增(运算符在后) n/a
… ++
后置递减(运算符在后) … --
16 逻辑非 从右到左 ! …
按位非 ~ …
一元加法 + …
一元减法 - …
前置递增 ++ …
前置递减 -- …
typeof typeof …
void void …
delete delete …
await await …
15 从右到左 … ** …
14 乘法 从左到右
… * …
除法 … / …
取模 … % …
13 加法 从左到右
… + …
减法 … - …
12 按位左移 从左到右 … << …
按位右移 … >> …
无符号右移 … >>> …
11 小于 从左到右 … < …
小于等于 … <= …
大于 … > …
大于等于 … >= …
in … in …
instanceof … instanceof …
10 等号 从左到右
… == …
非等号 … != …
全等号 … === …
非全等号 … !== …
9 按位与 从左到右 … & …
8 按位异或 从左到右 … ^ …
7 按位或 从左到右 `…
6 逻辑与 从左到右 … && …
5 逻辑或 从左到右 `…
4 条件运算符 从右到左 … ? … : …
3 赋值 从右到左 … = …
… += …
… -= …
… *= …
… /= …
… %= …
… <<= …
… >>= …
… >>>= …
… &= …
… ^= …
`…
2 yield 从右到左 yield …
yield* yield* …
1 展开运算符 n/a ...
0 逗号 从左到右 … , …
3 > 2 && 2 > 1
// return true
3 > 2 > 1
// 返回 false,因为 3 > 2 是 true,并且 true > 1 is false
// 加括号可以更清楚:(3 > 2) > 1

八、值的比较

1. 常见比较

在 JS 中的值的比较与数学很类型:

  • 大于/小于/大于等于/小于等于: a>b / a<b / a>=b / a<=b
  • 判断相等:
// 使用 ==,非严格等于,不关心值类型
// == 运算符会对比较的操作数做隐式类型转换,再比较
'1' == 1; // true
// 使用 ===,严格相等,关心值类型
// 将数字值 -0 和 +0 视为相等,并认为 Number.NaN 不等于 NaN。
'1' === 1; // false

值的比较
(图片来自:《MDN JavaScript 中的相等性判断》

  • 判断不相等:
    和判断相等一样,也有两种: != / !==

2. 相等性判断(Object.is())

另外 ES6 新增 Object.is 方法判断两个值是否相同,语法如下:

Object.is(value1, value2);

以下任意项成立则两个值相同:

  • 两个值都是 undefined
  • 两个值都是 null
  • 两个值都是 true 或者都是 false
  • 两个值是由相同个数的字符按照相同的顺序组成的字符串
  • 两个值指向同一个对象
  • 两个值都是数字并且
    • 都是正零 +0
    • 都是负零 -0
    • 都是 NaN
    • 都是除零和 NaN 外的其它同一个数字
      使用示例:
Object.is('foo', 'foo');     // true
Object.is(window, window);   // true
Object.is('foo', 'bar');     // false
Object.is([], []);           // false
var foo = { a: 1 };
var bar = { a: 1 };
Object.is(foo, foo);         // true
Object.is(foo, bar);         // false
Object.is(null, null);       // true
// 特例
Object.is(0, -0);            // false
Object.is(0, +0);            // true
Object.is(-0, -0);           // true
Object.is(NaN, 0/0);         // true

兼容性 Polyfill 处理:

if (!Object.is) {
  Object.is = function(x, y) {
    // SameValue algorithm
    if (x === y) { // Steps 1-5, 7-10
      // Steps 6.b-6.e: +0 != -0
      return x !== 0 || 1 / x === 1 / y;
    } else {
      // Step 6.a: NaN == NaN
      return x !== x && y !== y;
    }
  };
}

3. null 与 undefined 比较

对于相等性判断比较简单:

null == undefined;  // true
null === undefined; // false

对于其他比较,它们会先转换位数字:
null 转换为 0undefied 转换为 NaN

null > 0;  // false 1
null >= 0; // true  2
null == 0; // false 3
null < 1;  // true  4

需要注意:
null == 0; // false 这里是因为:undefinednull 在相等性检查 ==不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们之间互等外,不会等于任何其他的值。

undefined > 0;  // false  1
undefined > 1;  // false  2
undefined == 0; // false  3

第 1、2 行都返回 false 是因为 undefined 在比较中被转换为了 NaN,而 NaN 是一个特殊的数值型值,它与任何值进行比较都会返回 false
第 3 行返回 false 是因为这是一个相等性检查,而 undefined 只与 null 相等,不会与其他值相等。

九、alert / prompt / confirm

1. alert

显示一个警告对话框,上面显示有指定的文本内容以及一个“确定”按钮。
注意:弹出模态框,并暂停脚本,直到用户点击“确定”按钮。

// 语法
window.alert(message);
alert(message);
// 示例
alert('hello leo!');

message是要显示在对话框中的文本字符串,如果传入其他类型的值,会转换成字符串。

2. prompt

显示一个对话框,对话框中包含一条文字信息,用来提示用户输入文字。
注意:弹出模态框,并暂停脚本,直到用户点击“确定”按钮。
当点击确定返回文本,点击取消或按下 Esc 键返回 null
语法如下:

let result = window.prompt(text, value);
  • result 用来存储用户输入文字的字符串,或者是 null。
  • text 用来提示用户输入文字的字符串,如果没有任何提示内容,该参数可以省略不写。
  • value 文本输入框中的默认值,该参数也可以省略不写。不过在 Internet Explorer 7 和 8 中,省略该参数会导致输入框中显示默认值"undefined"。

3. confirm

Window.confirm() 方法显示一个具有一个可选消息和两个按钮(确定和取消)的模态对话框。
注意:弹出模态框,并暂停脚本,直到用户点击“确定”按钮。
语法如下:

let result = window.confirm(message);
  • message 是要在对话框中显示的可选字符串。
  • result 是一个布尔值,表示是选择确定还是取消 (true表示OK)。

十、条件运算符:if 和 '?'

1. if 语句

当 if 语句当条件表达式,会将表达式转换为布尔值,当为 truthy 时执行里面代码。
转换规则如:

  • 数字 0、空字符串 ""nullundefinedNaN 都会被转换成 false。因为他们被称为 “falsy” 值。
  • 其他值被转换为 true,所以它们被称为 “truthy”。

2. 三元运算符

条件(三元)运算符是 JavaScript 仅有的使用三个操作数的运算符。一个条件后面会跟一个问号(?),如果条件为 truthy ,则问号后面的表达式A将会执行;表达式A后面跟着一个冒号(:),如果条件为 falsy ,则冒号后面的表达式B将会执行。本运算符经常作为 [if](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/if...else) 语句的简捷形式来使用。
语法:

condition ? exprIfTrue : exprIfFalse
  • condition
    计算结果用作条件的表达式。
  • exprIfTrue
    如果表达式 condition 的计算结果是 truthy(它和 true 相等或者可以转换成 true ),那么表达式 exprIfTrue 将会被求值。
  • exprIfFalse
    如果表达式 condition 的计算结果是 falsy(它可以转换成 false ),那么表达式 exprIfFalse 将会被执行。
    示例:
let getUser = function(name){
	return name === 'leo' ? 'hello leo!' : 'unknow user';
}
// 可以简写如下:
let getUser = name => name === 'leo' ? 'hello leo!' : 'unknow user';
getUser('leo'); // "hello leo!"
getUser('pingan'); // "unknow user"

十一、逻辑运算符

详细可以阅读《MDN 逻辑运算符》

1. 运算符介绍

逻辑运算符如下表所示 (其中_expr_可能是任何一种类型, 不一定是布尔值):

运算符 语法 说明
逻辑与,AND(&& _expr1_ && _expr2_ expr**1** 可转换为 true,则返回 expr**2**;否则,返回 expr**1**
逻辑或,OR(` `)
逻辑非,NOT(! !_expr_ expr 可转换为 true,则返回 false;否则,返回 true
如果一个值可以被转换为 true,那么这个值就是所谓的 truthy,如果可以被转换为 false,那么这个值就是所谓的 falsy
会被转换为 false 的表达式有:
  • null
  • NaN
  • 0
  • 空字符串("" or '' or ````);
  • undefined
    尽管 &&|| 运算符能够使用非布尔值的操作数, 但它们依然可以被看作是布尔操作符,因为它们的返回值总是能够被转换为布尔值。如果要显式地将它们的返回值(或者表达式)转换为布尔值,请使用双重非运算符(即!!)或者Boolean构造函数。
    JavaScript 里有三个逻辑运算符:||(或),&&(与),!(非)。

2. 运算符示例

  • 逻辑与(&&)
    所有条件都为 true 才返回 true,否则为 false。
a1 = true  && true      // t && t 返回 true
a2 = true  && false     // t && f 返回 false
a3 = false && true      // f && t 返回 false
a4 = false && (3 == 4)  // f && f 返回 false
a5 = "Cat" && "Dog"     // t && t 返回 "Dog"
a6 = false && "Cat"     // f && t 返回 false
a7 = "Cat" && false     // t && f 返回 false
a8 = ''    && false     // f && f 返回 ""
a9 = false && ''        // f && f 返回 false
  • 逻辑或( || )
    所有条件有一个为 true 则返回 true,否则为 false。
o1 = true  || true      // t || t 返回 true
o2 = false || true      // f || t 返回 true
o3 = true  || false     // t || f 返回 true
o4 = false || (3 == 4)  // f || f 返回 false
o5 = "Cat" || "Dog"     // t || t 返回 "Cat"
o6 = false || "Cat"     // f || t 返回 "Cat"
o7 = "Cat" || false     // t || f 返回 "Cat"
o8 = ''    || false     // f || f 返回 false
o9 = false || ''        // f || f 返回 ""
  • 逻辑非( ! )
n1 = !true              // !t 返回 false
n2 = !false             // !f 返回 true
n3 = !''                // !f 返回 true
n4 = !'Cat'             // !t 返回 false
  • 双重非运( !! )
n1 = !!true                   // !!truthy 返回 true
n2 = !!{}                     // !!truthy 返回 true: 任何 对象都是 truthy 的…
n3 = !!(new Boolean(false))   // …甚至 .valueOf() 返回 false 的布尔值对象也是!
n4 = !!false                  // !!falsy 返回 false
n5 = !!""                     // !!falsy 返回 false
n6 = !!Boolean(false)         // !!falsy 返回 false

3. 布尔值转换规则

  • 将 && 转换为 ||
condi1 && confi2
// 转换为
!(!condi1 || !condi2)
  • 将 || 转换为 &&
condi1 || condi2
// 转换为
!(!condi1 && !condi2)

4. 短路取值

由于逻辑表达式的运算顺序是从左到右,也可以用以下规则进行"短路"计算:

  • (some falsy expression) && (_expr)_ 短路计算的结果为假。
  • (some truthy expression) || _(expr)_ 短路计算的结果为真。
    短路意味着上述表达式中的expr部分不会被执行,因此expr的任何副作用都不会生效(举个例子,如果expr是一次函数调用,这次调用就不会发生)。造成这种现象的原因是,整个表达式的值在第一个操作数被计算后已经确定了。看一个例子:
function A(){ console.log('called A'); return false; }
function B(){ console.log('called B'); return true; }
console.log( A() && B() );
// logs "called A" due to the function call,
// then logs false (which is the resulting value of the operator)
console.log( B() || A() );
// logs "called B" due to the function call,
// then logs true (which is the resulting value of the operator)

5. 注意

与运算 && 的优先级比或运算 || 要高。
所以代码 a && b || c && d 完全跟 && 表达式加了括号一样:(a && b) || (c && d)

十二、循环:while 和 for

1. while 循环

详细可以阅读《MDN  while》
while 语句可以在某个条件表达式为真的前提下,循环执行指定的一段代码,直到那个表达式不为真时结束循环。
如:

var n = 0;
var x = 0;
while (n < 3) {
  n++;
  x += n;
}

当循环体为单行时,可以不写大括号:

let i = 3;
while(i) console.log(i --);

2. do...while 循环

详细可以阅读《MDN  do...while》
do...while 语句创建一个执行指定语句的循环,直到condition值为 false。在执行statement 后检测condition,所以指定的statement至少执行一次。
如:

var result = '';
var i = 0;
do {
   i += 1;
   result += i + ' ';
} while (i < 5);

3. for 循环

详细可以阅读《MDN  for》
for 语句用于创建一个循环,它包含了三个可选的表达式,这三个表达式被包围在圆括号之中,使用分号分隔,后跟一个用于在循环中执行的语句(通常是一个块语句)。
语法如:

for (begin; condition; step) {
  // ……循环体……
}

示例:

for (let i = 0; i < 3; i++) {
  console.log(i);
}

描述:

begin i = 0 进入循环时执行一次。
condition i < 3 在每次循环迭代之前检查,如果为 false,停止循环。
body(循环体) alert(i) 条件为真时,重复运行。
step i++ 在每次循环体迭代后执行。

4. 可选的 for 表达式

for 语句头部圆括号中的所有三个表达式都是可选的。

  • 不指定表达式中初始化块
var i = 0;
for (; i < 3; i++) {
    console.log(i);
}
  • 不指定表达式中条件块,这就必须要求在循环体中结束循环,否则会出现死循环
for (var i = 0;; i++) {
   console.log(i);
   if (i > 3) break;
}
  • 不指定所有表达式,也需要在循环体中指定结束循环的条件
var i = 0;
for (;;) {
  if (i > 3) break;
  console.log(i);
  i++;
}

5. break 语句

详细可以阅读《MDN  break》
break 语句中止当前循环,switch语句或label 语句,并把程序控制流转到紧接着被中止语句后面的语句。
在 while 语句中:

function testBreak(x) {
  var i = 0;
  while (i < 6) {
    if (i == 3) {
      break;
    }
    i += 1;
  }
  return i * x;
}

另外,也可以为代码块做标记,并在 break 中指定要跳过的代码块语句的 label:

outer_block:{
  inner_block:{
    console.log ('1');
    break outer_block;      // breaks out of both inner_block and outer_block
    console.log (':-(');    // skipped
  }
  console.log ('2');        // skipped
}

需要注意的是:break 语句需要内嵌在它所应用的标签或代码块中,否则报错:

block_1:{
  console.log ('1');
  break block_2;            // SyntaxError: label not found
}
block_2:{
  console.log ('2');
}

6. continue 语句

continue 声明终止当前循环或标记循环的当前迭代中的语句执行,并在下一次迭代时继续执行循环。
break 语句的区别在于, continue 并不会终止循环的迭代,而是:

  • while 循环中,控制流跳转回条件判断;
  • for 循环中,控制流跳转到更新语句。
    注意:continue 也必须在对应循环内部,否则报错。
i = 0;
n = 0;
while (i < 5) {
   i++;
   if (i === 3) {
      continue;
   }
   n += i;
}

带 label:

var i = 0, 
    j = 8;
checkiandj: while (i < 4) {
   console.log("i: " + i);
   i += 1;
   checkj: while (j > 4) {
      console.log("j: "+ j);
      j -= 1;
      if ((j % 2) == 0)
         continue checkj;
      console.log(j + " is odd.");
   }
   console.log("i = " + i);
   console.log("j = " + j);
}

7. 注意

禁止 break/continue 在 ‘?’ 的右边:

(i > 5) ? console.log(i) : continue; // continue 不允许在这个位置

这样会提示语法错误。
请注意非表达式的语法结构不能与三元运算符 ? 一起使用。特别是 break/continue 这样的指令是不允许这样使用的。

8. 总结

三种循环:

  • while —— 每次迭代之前都要检查条件。
  • do..while —— 每次迭代后都要检查条件。
  • for (;;) —— 每次迭代之前都要检查条件,可以使用其他设置。
    通常使用 while(true) 来构造“无限”循环。这样的循环和其他循环一样,都可以通过 break 指令来终止。
    如果我们不想在当前迭代中做任何事,并且想要转移至下一次迭代,那么可以使用 continue 指令。
    break/continue 支持循环前的标签。标签是 break/continue 跳出嵌套循环以转到外部的唯一方法。

十三、"switch" 语句

switch 语句用来将表达式的值与 case 语句匹配,并执行与情况对应的语句。
switch 语句可以替代多个 if 判断,为多个分支选择的情况提供一个更具描述性的方式。

1. 语法

switch 语句至少包含一个 case 代码块和一个可选的 default 代码块:

switch(expression) {
  case 'value1':
    // do something ...
    [break]
   
  default:
    // ...
    [break]
}

expression 表达式的值与 value1 匹配时,则执行其中代码块。
如果没有 case  子句匹配,则会选择 default 子句执行,若连 default 子句都没有,则直接执行到 switch 结束。

2. 使用 case 分组

所谓 case 分组,就是与多个 case 分支共享同一段代码,如下面例子中 case 1  和 case 2

let a = 2;
switch (a) {
  case 1: // (*) 下面这两个 case 被分在一组
  case 2:
    console.log('case is 1 or 2!');
    break;
  case 3:
    console.log('case is 3!');
    break;
  default:
    console.log('The result is default.');
}
// 'case is 1 or 2!'

3. 注意点

  1. expression 表达式的值与 case 值的比较是严格相等:
function f(n){
    let a ;
    switch(n){
        case 1:
            a = 'number';
            break;
        case '1':
            a = 'string';
            break;
        default:
            a = 'default';
            break;
    }
    console.log(a)
}
f(1);   // number
f('1'); // string
  1. **如果没有 break,程序将不经过任何检查就会继续执行下一个 ****case** :
let a = 2 + 2;
switch (a) {
	case 3:
    console.log( 'Too small' );
  case 4:
    console.log( 'Exactly!' );
  case 5:
    console.log( 'Too big' );
  default:
    console.log( "I don't know such values" );
}
// Exactly!
// Too big
// I don't know such values
  1. **default** **放在 ****case** 之上不影响匹配:
function f(n){
  switch (n) {
    case 2:
      console.log(2);
      break;
    default:
      console.log('default')
      break;
    case 1:  
      console.log('1');
      break;
  }
}
f(1); // 1
f(2); // 2
f(3); // default
  • switch 语句中存在 let / const重复声明问题:
// 以下定义会报错
function f(n){
    switch(n){
        case 1:
            let msg = 'hello';
            console.log(1);
            break;
        case 2: 
            let msg = 'leo';
            break;
        default: 
            console.log('default');
            break;
    }
}
// Error: Uncaught SyntaxError: Identifier 'msg' has already been declared

这是由于两个 let 处于同一个块级作用域,所以它们被认为是同一变量名的重复声明。
解决方式,只需要将 case 语句包装在括号内即可解决:

function f(n){
    switch(n){
        case 1:{ // added brackets
            let msg = 'hello';
            console.log(msg);
            break;
        }
        case 2: {
            let msg = 'leo';
            console.log(msg);
            break;
        }
        default: 
            console.log('default');
            break;
    }
}

十四、函数

函数可以让一段代码被多次调用,避免重复代码。
如之前学习到的一些内置函数: alert(msg) / prompt(msg, default) / confirm(quesyion) 等。

1. 函数定义

定义函数有两种方式:函数声明函数表达式

1.1 函数声明

如定义一个简单 getUser 函数:

function getUser(name){
	return 'hello ' + name;
}
getUser('leo"); // 函数调用

通过函数声明来定义函数时,需要由以下几部分组成:

  • 函数名称 - getUser
  • 函数参数列表 - name
  • 函数的 JS 执行语句 - return 'hello ' + name

1.2 函数表达式

类似声明变量,还是以 getUser 为例:

let getUser = function(name){
	return 'hello ' + name;
}

另外,函数表达式也可以提供函数名,并用于函数内部指代函数本身:

let fun = function f(n){
    return n < 3 ? 1 : n * f(n - 1);
}
fun(3);  // 3
fun(5);  // 60

2. 函数调用

当定义一个函数后,它并不会自动执行,而是需要使用函数名称进行调用,如上面例子:

fun(3);  // 3

只要注意:
使用 函数表达式 定义函数时,调用函数的方法必须写在定义之后,否则报错:

console.log(fun());  // Uncaught ReferenceError: fun is not defined
let fun = function(){return 1};

而使用 函数声明 则不会出现该问题:

console.log(fun());  // 1
function fun(){return 1};

原因就是:函数提升仅适用于函数声明,而不适用于函数表达式。

3. 函数中的变量

在函数中,可以使用局部变量和外部变量。

3.1 局部变量

函数中声明的变量只能在该函数内可见。

let fun = function(){
	let name = 'leo';
}
fun();
console.log(name); // Uncaught ReferenceError: name is not defined

3.2 全局变量

函数内可以使用外部变量,并且可以修改外部变量的值。

let name = 'leo';
let fun = function(){
	let text = 'Hello, ' + name;
  console.log(text);
}
fun(); // Hello, leo

当函数内也有与外部变量名称相同的变量,会忽略外部变量:

let name = 'leo';
let fun = function(){
  let name = 'pingan8787';
	let text = 'Hello, ' + name;
  console.log(text);
}
fun(); // Hello, pingan8787

4. 函数参数

从ECMAScript 6开始,有两个新的类型的参数:默认参数,剩余参数。

4.1 默认参数

若函数没有传入参数,则参数默认值为undefined,通常设置参数默认值是这样做的:

// ES6 之前,没有设置默认值
function f(a, b){
    b = b ? b : 1;
    return a * b;
}
f(2,3);  // 6
f(2);    // 2
// ES6,设置默认值
function f(a, b = 1){
    return a * b;
}
f(2,3);  // 6
f(2);    // 2

4.2 剩余参数

可以将参数中不确定数量的参数表示成数组,如下:

function f (a, ...b){
    console.log(a, b);
}
f(1,2,3,4); // a => 1 b => [2, 3, 4]

既然讲到参数,那就不能少了 arguments 对象。

4.3 arguments 对象

函数的实际参数会被保存在一个类似数组的arguments对象中。在函数内,我们可以使用 arguments 对象获取函数的所有参数:

let fun = function(){
    console.log(arguments);
    console.log(arguments.length);
}
fun('leo'); 
// Arguments ["leo", callee: ƒ, Symbol(Symbol.iterator): ƒ] 
// 1

以一个实际示例介绍,实现将任意数量参数连接成一个字符串,并输出的函数:

let argumentConcat = function(separator){
	let result = '', i;
  for(i = 1; i < arguments.length; i ++){
  	result += arguments[i] + separator;
  }
  return result;
}
argumentConcat(',', 'leo', 'pingan'); //"leo,pingan,"

5. 函数返回值

在函数任意位置,指定 return 指令来停止函数的执行,并返回函数指定的返回值。

let sum = function(a, b){
	return a + b;
};
let res = sum(1, 2);
console.log(res); // 3

默认空值的 return 或没有 return 的函数返回值为 undefined

十五、函数表达式

函数表达式是一种函数定义方式,在前面章节中已经介绍到了,这个部分将着重介绍 函数表达式函数声明 的区别:

1. 语法差异

// 函数表达式
let fun = function(){};
// 函数声明
function fun(){}

2. 创建时机差异

函数表达式会在代码执行到达时被创建,并且仅从那一刻可用。
而函数声明被定义之前,它就可以被调用。

// 函数表达式
fun();  // Uncaught ReferenceError: fun is not defined
let fun = function(){console.log('leo')};
// 函数声明
fun();  // "leo"
function fun(){console.log('leo')};

3. 使用建议

建议优先考虑函数声明语法,它能够为组织代码提供更多灵活性,因为我们可以在声明函数前调用该函数。

十六、箭头函数

本章节简单介绍箭头函数基础知识,后面章节会完整介绍。
函数箭头表达式是ES6新增的函数表达式的语法,也叫胖箭头函数,变化:更简洁的函数和this

1. 代码更简洁

// 有1个参数
let f = v => v;
// 等同于
let f = function (v){return v};
// 有多个参数
let f = (v, i) => {return v + i};
// 等同于
let f = function (v, i){return v + i};
// 没参数
let f = () => 1;
// 等同于
let f = function (){return 1};
let arr = [1,2,3,4];
arr.map(ele => ele + 1);  // [2, 3, 4, 5]

2. 注意点

  1. 箭头函数不存在this
  2. 箭头函数不能当做构造函数,即不能用new实例化;
  3. 箭头函数不存在arguments对象,即不能使用,可以使用rest参数代替;
  4. 箭头函数不能使用yield命令,即不能用作Generator函数。
    一个简单的例子:
function Person(){
  this.age = 0;
  setInterval(() => {
    this.age++;
  }, 1000);
}
var p = new Person(); // 定时器一直在执行 p的值一直变化

总结

本文作为《初中级前端 JavaScript 自测清单》第一部分,介绍的内容以常用基础知识为主,并在学习资料中,将知识点结合实际开发中遇到的场景进行展开介绍。希望能帮助大家自测自己的 JavaScript 水平并查缺补漏,温故知新。

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推荐 https://github.com/pingan8787/Leo_Reading/issues
ES小册 js.pingan8787.com
语雀知识库 Cute-FrontEnd

bg

【基础】初中级前端 JavaScript 自测清单 - 2

前言

《初中级前端 JavaScript 自测清单 - 1》部分中,和大家简单过了一遍 JavaScript 基础知识,没看过的朋友可以回顾一下😁

本系列文章是我在我们团队内部的“现代 JavaScript 突击队”,第一期学习内容为《现代 JavaScript 教程》系列的第二部分输出内容,希望这份自测清单,能够帮助大家巩固知识,温故知新。

本部分内容,以 JavaScript 对象为主,大致包括以下内容:
初中级前端 JavaScript 自测清单 2.png

一、对象

JavaScript 有八种数据额类型,有七种原始类型,它们值只包含一种类型(字符串,数字或其他),而对象是用来保存键值对和更复杂实体。
我们可以通过使用带有可选属性列表的花括号 **{...}** 来创建对象,一个属性就是一个键值对 {"key" : "value"} ,其中键( key )是一个字符串(或称属性名),值( value )可以是任何类型。

1. 创建对象

我们可以使用 2 种方式来创建一个新对象:

// 1. 通过“构造函数”创建
let user = new Object();

// 2. 通过“字面量”创建
let user = {};

2. 对象文本和属性

创建对象时,可以初始化对象的一些属性:

let user = {
	name : 'leo',
  age  : 18
}

然后可以对该对象进行属性对增删改查操作:

// 增加属性
user.addr = "China";
// user => {name: "leo", age: 18, addr: "China"}

// 删除属性
delete user.addr
// user => {name: "leo", age: 18}

// 修改属性
user.age  = 20;
// user => {name: "leo", age: 20}

// 查找属性
user.age;
// 20

3. 方括号的使用

当然对象的键( key )也可以是多词属性,但必须加引号,使用的时候,必须使用方括号( [] )读取:

let user = {
	name : 'leo',
  "my interest" : ["coding", "football", "cycling"]
}
user["my interest"]; // ["coding", "football", "cycling"]
delete user["my interest"];

我们也可以在方括号中使用变量,来获取属性值:

let key = "name";
let user = {
	name : "leo",
  age  : 18 
}
// ok
user[key]; // "leo"
user[key] = "pingan";

// error
user.key; // undefined

4. 计算属性

创建对象时,可以在对象字面量中使用方括号,即 计算属性

let key = "name";
let inputKey = prompt("请输入key", "age");
let user = {
	[key] : "leo",
  [inputKey] : 18
}
// 当用户在 prompt 上输入 "age" 时,user 变成下面样子:
// {name: "leo", age: 18}

当然,计算属性也可以是表达式:

let key = "name";
let user = {
	["my_" + key] : "leo"
}
user["my_" + key]; // "leo"

5. 属性名简写

实际开发中,可以将相同的属性名和属性值简写成更短的语法:

// 原本书写方式
let getUser = function(name, age){
  // ...
	return {
		name: name,
    age: age
	}
}

// 简写方式
let getUser = function(name, age){
  // ...
	return {
		name,
    age
	}
}

也可以混用:

// 原本书写方式
let getUser = function(name, age){
  // ...
	return {
		name: name,
    age: 18
	}
}

// 简写方式
let getUser = function(name, age){
  // ...
	return {
		name,
    age: 18
	}
}

6. 对象属性存在性检测

6.1 使用 in 关键字

该方法可以判断对象的自有属性和继承来的属性是否存在。

let user = {name: "leo"};
"name" in user;            //true,自有属性存在
"age"  in user;            //false
"toString" in user;     //true,是一个继承属性

6.2使用对象的 hasOwnProperty() 方法。

该方法只能判断自有属性是否存在,对于继承属性会返回 false

let user = {name: "leo"};
user.hasOwnProperty("name");       //true,自有属性中有 name
user.hasOwnProperty("age");        //false,自有属性中不存在 age
user.hasOwnProperty("toString");   //false,这是一个继承属性,但不是自有属性

6.3 用 undefined 判断

该方法可以判断对象的自有属性和继承属性

let user = {name: "leo"};
user.name !== undefined;        // true
user.age  !== undefined;        // false
user.toString !== undefined     // true

该方法存在一个问题,如果属性的值就是 undefined  的话,该方法不能返回想要的结果:

let user = {name: undefined};
user.name !== undefined;        // false,属性存在,但值是undefined
user.age  !== undefined;        // false
user.toString !== undefined;    // true

6.4 在条件语句中直接判断

let user = {};
if(user.name) user.name = "pingan";
//如果 name 是 undefined, null, false, " ", 0 或 NaN,它将保持不变

user; // {}

7. 对象循环遍历

当我们需要遍历对象中每一个属性,可以使用 for...in 语句来实现

7.1 for...in 循环

for...in 语句以任意顺序遍历一个对象的除 Symbol 以外的可枚举属性。
注意for...in 不应该应用在一个数组,其中索引顺序很重要。

let user = {
	name : "leo",
  age  : 18
}

for(let k in user){
	console.log(k, user[k]);
}
// name leo
// age 18

7.2 ES7 新增方法

ES7中新增加的 Object.values()Object.entries()与之前的Object.keys()类似,返回数组类型。

1. Object.keys()

返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的健名。

let user = { name: "leo", age: 18};
Object.keys(user); // ["name", "age"]

2. Object.values()

返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值。

let user = { name: "leo", age: 18};
Object.values(user); // ["leo", 18]

如果参数不是对象,则返回空数组:

Object.values(10);   // []
Object.values(true); // []

3. Object.entries()

返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历属性的键值对数组。

let user = { name: "leo", age: 18};
Object.entries(user);
// [["name","leo"],["age",18]]

手动实现Object.entries()方法:

// Generator函数实现:  
function* entries(obj){
    for (let k of Object.keys(obj)){
        yield [k ,obj[k]];
    }
}
// 非Generator函数实现:
function entries (obj){
    let arr = [];
    for(let k of Object.keys(obj)){
        arr.push([k, obj[k]]);
    }
    return arr;
}

4. Object.getOwnPropertyNames(Obj)

该方法返回一个数组,它包含了对象 Obj 所有拥有的属性(无论是否可枚举)的名称。

let user = { name: "leo", age: 18};
Object.getOwnPropertyNames(user);
// ["name", "age"]

二、对象拷贝

参考文章《搞不懂JS中赋值·浅拷贝·深拷贝的请看这里》

1. 赋值操作

首先回顾下基本数据类型和引用数据类型:

  • 基本类型

概念:基本类型值在内存中占据固定大小,保存在栈内存中(不包含闭包中的变量)。
常见包括:undefined,null,Boolean,String,Number,Symbol

  • 引用类型

概念:引用类型的值是对象,保存在堆内存中。而栈内存存储的是对象的变量标识符以及对象在堆内存中的存储地址(引用),引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
常见包括:Object,Array,Date,Function,RegExp等

1.1 基本数据类型赋值

在栈内存中的数据发生数据变化的时候,系统会自动为新的变量分配一个新的之值在栈内存中,两个变量相互独立,互不影响的。

let user  = "leo";
let user1 = user;
user1 = "pingan";
console.log(user);  // "leo"
console.log(user1); // "pingan" 

1.2 引用数据类型赋值

在 JavaScript 中,变量不存储对象本身,而是存储其“内存中的地址”,换句话说就是存储对其的“引用”。
如下面 leo  变量只是保存对user 对象对应引用:

let user = { name: "leo", age: 18};
let leo  = user;

其他变量也可以引用 user 对象:

let leo1 = user;
let leo2 = user;

但是由于变量保存的是引用,所以当我们修改变量 leo \ leo1 \ leo2 这些值时,也会改动到引用对象 user ,但当 user 修改,则其他引用该对象的变量,值都会发生变化:

leo.name = "pingan";
console.log(leo);   // {name: "pingan", age: 18}
console.log(leo1);  // {name: "pingan", age: 18}
console.log(leo2);  // {name: "pingan", age: 18}
console.log(user);  // {name: "pingan", age: 18}

user.name = "pingan8787";
console.log(leo);   // {name: "pingan8787", age: 18}
console.log(leo1);  // {name: "pingan8787", age: 18}
console.log(leo2);  // {name: "pingan8787", age: 18}
console.log(user);  // {name: "pingan8787", age: 18}

这个过程中涉及变量地址指针指向问题,这里暂时不展开讨论,有兴趣的朋友可以网上查阅相关资料。

2. 对象比较

当两个变量引用同一个对象时,它们无论是 == 还是 === 都会返回 true

let user = { name: "leo", age: 18};
let leo  = user;
let leo1 = user;
leo ==  leo1;   // true
leo === leo1;   // true
leo ==  user;   // true
leo === user;   // true

但如果两个变量是空对象 {} ,则不相等:

let leo1 = {};
let leo2 = {};
leo1 ==  leo2;  // false
leo1 === leo2;  // false

3. 浅拷贝

3.1 概念

概念:新的对象复制已有对象中非对象属性的值和对象属性的引用。也可以理解为:一个新的对象直接拷贝已存在的对象的对象属性的引用,即浅拷贝。

浅拷贝只对第一层属性进行了拷贝,当第一层的属性值是基本数据类型时,新的对象和原对象互不影响,但是如果第一层的属性值是复杂数据类型,那么新对象和原对象的属性值其指向的是同一块内存地址。

通过示例代码演示没有使用浅拷贝场景:

// 示例1 对象原始拷贝
let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}};
let leo = user;
leo.name = "leo1";
leo.skill.CSS = 90;
console.log(leo.name);      // "leo1"
console.log(user.name);     // "leo1"
console.log(leo.skill.CSS); // 90
console.log(user.skill.CSS);// 90

// 示例2 数组原始拷贝
let user = ["leo", "pingan", {name: "pingan8787"}];
let leo  = user;
leo[0] = "pingan888";
leo[2]["name"] = "pingan999";
console.log(leo[0]);          // "pingan888"
console.log(user[0]);         // "pingan888"
console.log(leo[2]["name"]);  // "pingan999"
console.log(user[2]["name"]); // "pingan999"

从上面示例代码可以看出:
由于对象被直接拷贝,相当于拷贝 引用数据类型 ,所以在新对象修改任何值时,都会改动到源数据。

接下来实现浅拷贝,对比以下。

3.2 实现浅拷贝

1. Object.assign() 

语法: Object.assign(target, ...sources)
ES6中拷贝对象的方法,接受的第一个参数是拷贝的目标target,剩下的参数是拷贝的源对象sources(可以是多个)。
详细介绍,可以阅读文档《MDN Object.assign》

// 示例1 对象浅拷贝
let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}};
let leo = Object.assign({}, user);
leo.name = "leo1";
leo.skill.CSS = 90;
console.log(leo.name);      // "leo1" ⚠️ 差异!
console.log(user.name);     // "leo"  ⚠️ 差异!
console.log(leo.skill.CSS); // 90
console.log(user.skill.CSS);// 90


// 示例2 数组浅拷贝
let user = ["leo", "pingan", {name: "pingan8787"}];
let leo  = Object.assign({}, user);
leo[0] = "pingan888";
leo[2]["name"] = "pingan999";
console.log(leo[0]);          // "pingan888"  ⚠️ 差异!
console.log(user[0]);         // "leo"        ⚠️ 差异!
console.log(leo[2]["name"]);  // "pingan999"
console.log(user[2]["name"]); // "pingan999"

从打印结果可以看出,浅拷贝只是在根属性(对象的第一层级)创建了一个新的对象,但是对于属性的值是对象的话只会拷贝一份相同的内存地址。

Object.assign() 使用注意:

  • 只拷贝源对象的自身属性(不拷贝继承属性);
  • 不会拷贝对象不可枚举的属性;
  • 属性名为Symbol 值的属性,可以被Object.assign拷贝;
  • undefinednull无法转成对象,它们不能作为Object.assign参数,但是可以作为源对象。
Object.assign(undefined); // 报错
Object.assign(null);      // 报错

Object.assign({}, undefined); // {}
Object.assign({}, null);      // {}

let user = {name: "leo"};
Object.assign(user, undefined) === user; // true
Object.assign(user, null)      === user; // true

2. Array.prototype.slice()

语法: arr.slice([begin[, end]])
slice() 方法返回一个新的数组对象,这一对象是一个由 beginend 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。
详细介绍,可以阅读文档《MDN Array slice》

// 示例 数组浅拷贝
let user = ["leo", "pingan", {name: "pingan8787"}];
let leo  = Array.prototype.slice.call(user);
leo[0] = "pingan888";
leo[2]["name"] = "pingan999";
console.log(leo[0]);          // "pingan888"  ⚠️ 差异!
console.log(user[0]);         // "leo"        ⚠️ 差异!
console.log(leo[2]["name"]);  // "pingan999"
console.log(user[2]["name"]); // "pingan999"

3. Array.prototype.concat()

语法: var new_array = old_array.concat(value1[, value2[, ...[, valueN]]])
concat() 方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。
详细介绍,可以阅读文档《MDN Array concat》

let user  = [{name: "leo"},   {age: 18}];
let user1 = [{age: 20},{addr: "fujian"}];
let user2 = user.concat(user1);
user1[0]["age"] = 25;
console.log(user);  // [{"name":"leo"},{"age":18}]
console.log(user1); // [{"age":25},{"addr":"fujian"}]
console.log(user2); // [{"name":"leo"},{"age":18},{"age":25},{"addr":"fujian"}]

Array.prototype.concat 也是一个浅拷贝,只是在根属性(对象的第一层级)创建了一个新的对象,但是对于属性的值是对象的话只会拷贝一份相同的内存地址。

4. 拓展运算符(...)

语法: var cloneObj = { ...obj };
扩展运算符也是浅拷贝,对于值是对象的属性无法完全拷贝成2个不同对象,但是如果属性都是基本类型的值的话,使用扩展运算符也是优势方便的地方。

let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}};
let leo = {...user};
leo.name = "leo1";
leo.skill.CSS = 90;
console.log(leo.name);      // "leo1" ⚠️ 差异!
console.log(user.name);     // "leo"  ⚠️ 差异!
console.log(leo.skill.CSS); // 90
console.log(user.skill.CSS);// 90

3.3 手写浅拷贝

实现原理:新的对象复制已有对象中非对象属性的值和对象属性的引用,也就是说对象属性并不复制到内存。

function cloneShallow(source) {
    let target = {};
    for (let key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            target[key] = source[key];
        }
    }
    return target;
}
  • for in

for...in语句以任意顺序遍历一个对象自有的、继承的、可枚举的、非Symbol的属性。对于每个不同的属性,语句都会被执行。

  • hasOwnProperty

该函数返回值为布尔值,所有继承了 Object 的对象都会继承到 hasOwnProperty 方法,和 in 运算符不同,该函数会忽略掉那些从原型链上继承到的属性和自身属性。
语法:obj.hasOwnProperty(prop)
prop 是要检测的属性字符串名称或者Symbol

4. 深拷贝

4.1 概念

复制变量值,对于引用数据,则递归至基本类型后,再复制。深拷贝后的对象与原来的对象完全隔离,互不影响,对一个对象的修改并不会影响另一个对象。

4.2 实现深拷贝

1. JSON.parse(JSON.stringify())

其原理是把一个对象序列化成为一个JSON字符串,将对象的内容转换成字符串的形式再保存在磁盘上,再用JSON.parse() 反序列化将JSON字符串变成一个新的对象。

let user = { name: "leo", skill: { JavaScript: 90, CSS: 80}};
let leo = JSON.parse(JSON.stringify(user));
leo.name = "leo1";
leo.skill.CSS = 90;
console.log(leo.name);      // "leo1" ⚠️ 差异!
console.log(user.name);     // "leo"  ⚠️ 差异!
console.log(leo.skill.CSS); // 90 ⚠️ 差异!
console.log(user.skill.CSS);// 80 ⚠️ 差异!

JSON.stringify() 使用注意:

  • 拷贝的对象的值中如果有函数, undefinedsymbol 则经过 JSON.stringify() `序列化后的JSON字符串中这个键值对会消失;
  • 无法拷贝不可枚举的属性,无法拷贝对象的原型链;
  • 拷贝 Date 引用类型会变成字符串;
  • 拷贝 RegExp 引用类型会变成空对象;
  • 对象中含有 NaNInfinity-Infinity ,则序列化的结果会变成 null
  • 无法拷贝对象的循环应用(即 obj[key] = obj )。

2. 第三方库

4.3 手写深拷贝

核心**是递归,遍历对象、数组直到里边都是基本数据类型,然后再去复制,就是深度拷贝。 实现代码:

const isObject = obj => typeof obj === 'object' && obj != null;

function cloneDeep(source) {
    if (!isObject(source)) return source; // 非对象返回自身
    const target = Array.isArray(source) ? [] : {};
    for(var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
            if (isObject(source[key])) {
                target[key] = cloneDeep(source[key]); // 注意这里
            } else {
                target[key] = source[key];
            }
        }
    }
    return target;
}

该方法缺陷: 遇到循环引用,会陷入一个循环的递归过程,从而导致爆栈。
其他写法,可以阅读《如何写出一个惊艳面试官的深拷贝?》

5. 小结

浅拷贝:将对象的每个属性进行依次复制,但是当对象的属性值是引用类型时,实质复制的是其引用,当引用指向的值改变时也会跟着变化。

深拷贝:复制变量值,对于引用数据,则递归至基本类型后,再复制。深拷贝后的对象与原来的对象完全隔离,互不影响,对一个对象的修改并不会影响另一个对象。

深拷贝和浅拷贝是针对复杂数据类型来说的,浅拷贝只拷贝一层,而深拷贝是层层拷贝。
深拷贝和浅拷贝

三、垃圾回收机制(GC)

垃圾回收(Garbage Collection,缩写为GC)是一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。垃圾回收最早起源于LISP语言。
目前许多语言如Smalltalk、Java、C#和D语言都支持垃圾回收器,我们熟知的 JavaScript 具有自动垃圾回收机制。

在 JavaScript 中,原始类型的数据被分配到栈空间中,引用类型的数据会被分配到堆空间中。

1. 栈空间中的垃圾回收

当函数 showName 调用完成后,通过下移 ESP(Extended Stack Pointer)指针,来销毁 showName 函数,之后调用其他函数时,将覆盖掉旧内存,存放另一个函数的执行上下文,实现垃圾回收。

图片来自《浏览器工作原理与实践》

2. 堆空间中的垃圾回收

堆中数据垃圾回收策略的基础是:代际假说(The Generational Hypothesis)。即:

  1. 大部分对象在内存中存在时间极短,很多对象很快就不可访问。
  2. 不死的对象将活得更久。

这两个特点不仅仅适用于 JavaScript,同样适用于大多数的动态语言,如 Java、Python 等。
V8 引擎将堆空间分为新生代(存放生存时间短的对象)和老生代(存放生存时间长的对象)两个区域,并使用不同的垃圾回收器。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

不管是哪种垃圾回收器,都使用相同垃圾回收流程:标记活动对象和非活动对象,回收非活动对象的内存,最后内存整理。
**

1.1 副垃圾回收器

使用 Scavenge 算法处理,将新生代空间对半分为两个区域,一个对象区域,一个空闲区域。

图片来自《浏览器工作原理与实践》

执行流程:

  • 新对象存在在对象区域,当对象区域将要写满时,执行一次垃圾回收;
  • 垃圾回收过程中,首先对对象区域中的垃圾做标记,然后副垃圾回收器将存活的对象复制并有序排列到空闲区域,相当于完成内存整理。
  • 复制完成后,将对象区域和空闲区域翻转,完成垃圾回收操作,这也让新生代中两块区域无限重复使用。

当然,这也存在一些问题:若复制操作的数据较大则影响清理效率。
JavaScript 引擎的解决方式是:将新生代区域设置得比较小,并采用对象晋升策略(经过两次回收仍存活的对象,会被移动到老生区),避免因为新生代区域较小引起存活对象装满整个区域的问题。

1.2 主垃圾回收器

分为:标记 - 清除(Mark-Sweep)算法,和标记 - 整理(Mark-Compact)算法

a)标记 - 清除(Mark-Sweep)算法
过程:

  • 标记过程:从一组根元素开始遍历整个元素,能到达的元素为活动对象,反之为垃圾数据;
  • 清除过程:清理被标记的数据,并产生大量碎片内存。(缺点:导致大对象无法分配到足够的连续内存)


图片来自《浏览器工作原理与实践》

b)标记 - 整理(Mark-Compact)算法
过程:

  • 标记过程:从一组根元素开始遍历整个元素,能到达的元素为活动对象,反之为垃圾数据;
  • 整理过程:将所有存活的对象,向一段移动,然后清除端边界以外的内容。


图片来自《浏览器工作原理与实践》

3. 拓展阅读

1.《图解Java 垃圾回收机制》
2.《MDN 内存管理》

四、对象方法和 this

1. 对象方法

具体介绍可阅读 《MDN 方法的定义》
将作为对象属性的方法称为“对象方法”,如下面 user 对象的 say 方法:

let user = {};
let say = function(){console.log("hello!")};

user.say = say;  // 赋值到对象上
user.say(); // "hello!"

也可以使用更加简洁的方法:

let user = {
	say: function(){}
  
  // 简写为
	say (){console.log("hello!")}

	// ES8 async 方法
	async say (){/.../}
}
user.say();

当然对象方法的名称,还支持计算的属性名称作为方法名:

const hello = "Hello";
let user = {
	['say' + hello](){console.log("hello!")}
}
user['say' + hello](); // "hello!"

另外需要注意的是:所有方法定义不是构造函数,如果您尝试实例化它们,将抛出TypeError

let user = {
	say(){};
}
new user.say; // TypeError: user.say is not a constructor

2. this

2.1 this 简介

当对象方法需要使用对象中的属性,可以使用 this 关键字:

let user = {
	name : 'leo',
  say(){ console.log(`hello ${this.name}`)}
}

user.say(); // "hello leo"

当代码 user.say() 执行过程中, this 指的是 user 对象。当然也可以直接使用变量名 user 来引用 say() 方法:

let user = {
	name : 'leo',
  say(){ console.log(`hello ${user.name}`)}
}

user.say(); // "hello leo"

但是这样并不安全,因为 user 对象可能赋值给另外一个变量,并且将其他值赋值给 user 对象,就可能导致报错:

let user = {
	name : 'leo',
  say(){ console.log(`hello ${user.name}`)}
}

let leo = user;
user = null;

leo.say(); // Uncaught TypeError: Cannot read property 'name' of null

但将  user.name  改成 this.name 代码便正常运行。

2.2 this 取值

this 的值是在 代码运行时计算出来 的,它的值取决于代码上下文:

let user = { name: "leo"};
let admin = {name: "pingan"};
let say = function (){
	console.log(`hello ${this.name}`)
};

user.fun = say;
admin.fun = say;

// 函数内部 this 是指“点符号前面”的对象
user.fun();     // "hello leo"
admin.fun();    // "hello pingan"
admin['fun'](); // "hello pingan"

规则:如果 obj.fun() 被调用,则 thisfun 函数调用期间是 obj ,所以上面的 this 先是 user ,然后是 admin

但是在全局环境中,无论是否开启严格模式, this 都指向全局对象

console.log(this == window); // true

let a = 10;
this.b = 10;
a === this.b; // true

2.3 箭头函数没有自己的 this

箭头函数比较特别,没有自己的 this ,如果有引用 this 的话,则指向外部正常函数,下面例子中, this 指向 user.say() 方法:

let user = {
	name : 'leo',
  say : () => {
  	console.log(`hello ${this.name}`);
  },
  hello(){
		let fun = () => console.log(`hello ${this.name}`);
    fun();
	}
}

user.say();   // hello      => say() 外部函数是 window
user.hello(); // hello leo  => fun() 外部函数是 hello

2.4 call / apply / bind

详细可以阅读《js基础-关于call,apply,bind的一切》
当我们想把 this 值绑定到另一个环境中,就可以使用 call / apply / bind 方法实现:

var user = { name: 'leo' };
var name = 'pingan';
function fun(){
	return console.log(this.name); // this 的值取决于函数调用方式
}

fun();           // "pingan"
fun.call(user);  // "leo"
fun.apply(user); // "leo"

注意:这里的 var name = 'pingan'; 需要使用 var 来声明,使用 let 的话, window 上将没有 name 变量。

三者语法如下:

fun.call(thisArg, param1, param2, ...)
fun.apply(thisArg, [param1,param2,...])
fun.bind(thisArg, param1, param2, ...)

五、构造函数和 new 运算符

1. 构造函数

构造函数的作用在于 实现可重用的对象创建代码
通常,对于构造函数有两个约定:

  • 命名时首字母大写;
  • 只能使用 new 运算符执行。

new 运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。
语法如下:

new constructor[([arguments])]

参数如下:

  • constructor一个指定对象实例的类型的类或函数。
  • arguments一个用于被 constructor 调用的参数列表。

2. 简单示例

举个简单示例:

function User (name){
	this.name = name;
  this.isAdmin = false; 
}
const leo = new User('leo');
console.log(leo.name, leo.isAdmin); // "leo" false

3. new 运算符操作过程

当一个函数被使用 new 运算符执行时,它按照以下步骤:

  1. 一个新的空对象被创建并分配给 this
  2. 函数体执行。通常它会修改 this,为其添加新的属性。
  3. 返回 this 的值。

以前面 User 方法为例:

function User(name) {
  // this = {};(隐式创建)

  // 添加属性到 this
  this.name = name;
  this.isAdmin = false;

  // return this;(隐式返回)
}
const leo = new User('leo');
console.log(leo.name, leo.isAdmin); // "leo" false

当我们执行 new User('leo') 时,发生以下事情:

  1. 一个继承自 User.prototype 的新对象被创建;
  2. 使用指定参数调用构造函数 User ,并将 this 绑定到新创建的对象;
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。

需要注意

  1. 一般情况下,构造函数不返回值,但是开发者可以选择主动返回对象,来覆盖正常的对象创建步骤;
  2. new User 等同于 new User() ,只是没有指定参数列表,即 User 不带参数的情况;
let user = new User; // <-- 没有参数
// 等同于
let user = new User();
  1. 任何函数都可以作为构造器,即都可以使用 new 运算符运行。

4. 构造函数中的方法

在构造函数中,也可以将方法绑定到 this 上:

function User (name){
	this.name = name;
  this.isAdmin = false; 
	this.sayHello = function(){
		console.log("hello " + this.name);
	}
}
const leo = new User('leo');
console.log(leo.name, leo.isAdmin); // "leo" false
leo.sayHello(); // "hello leo"

六、可选链 "?."

详细介绍可以查看 《MDN 可选链操作符》

1. 背景介绍

在实际开发中,常常出现下面几种报错情况:

// 1. 对象中不存在指定属性
const leo = {};
console.log(leo.name.toString()); 
// Uncaught TypeError: Cannot read property 'toString' of undefined

// 2. 使用不存在的 DOM 节点属性
const dom = document.getElementById("dom").innerHTML; 
// Uncaught TypeError: Cannot read property 'innerHTML' of null

在可选链 ?. 出现之前,我们会使用短路操作 && 运算符来解决该问题:

const leo = {};
console.log(leo && leo.name && leo.name.toString()); // undefined

这种写法的缺点就是 太麻烦了

2. 可选链介绍

可选链 ?. 是一种 访问嵌套对象属性的防错误方法 。即使中间的属性不存在,也不会出现错误。
如果可选链 ?. 前面部分是 undefined 或者 null,它会停止运算并返回 undefined

语法:

obj?.prop
obj?.[expr]
arr?.[index]
func?.(args)

**
我们改造前面示例代码:

// 1. 对象中不存在指定属性
const leo = {};
console.log(leo?.name?.toString()); 
// undefined

// 2. 使用不存在的 DOM 节点属性
const dom = document?.getElementById("dom")?.innerHTML; 
// undefined

3. 使用注意

可选链虽然好用,但需要注意以下几点:

  1. 不能过度使用可选链

我们应该只将 ?. 使用在一些属性或方法可以不存在的地方,以上面示例代码为例:

const leo = {};
console.log(leo.name?.toString()); 

这样写会更好,因为 leo 对象是必须存在,而 name 属性则可能不存在。

  1. 可选链 ?. 之前的变量必须已声明

在可选链 ?. 之前的变量必须使用 let/const/var 声明,否则会报错:

leo?.name;
// Uncaught ReferenceError: leo is not defined
  1. 可选链不能用于赋值
let object = {};
object?.property = 1; 
// Uncaught SyntaxError: Invalid left-hand side in assignment
  1. 可选链访问数组元素的方法
let arrayItem = arr?.[42];

4. 其他情况:?.() 和 ?.[]

需要说明的是 ?. 是一个特殊的语法结构,而不是一个运算符,它还可以与其 ()[] 一起使用:

4.1 可选链与函数调用 ?.()

?.() 用于调用一个可能不存在的函数,比如:

let user1 = {
  admin() {
    alert("I am admin");
  }
}

let user2 = {};

user1.admin?.(); // I am admin
user2.admin?.();

?.() 会检查它左边的部分:如果 admin 函数存在,那么就调用运行它(对于 user1)。否则(对于 user2)运算停止,没有错误。

4.2 可选链和表达式 ?.[]

?.[] 允许从一个可能不存在的对象上安全地读取属性。

let user1 = {
  firstName: "John"
};

let user2 = null; // 假设,我们不能授权此用户

let key = "firstName";

alert( user1?.[key] ); // John
alert( user2?.[key] ); // undefined

alert( user1?.[key]?.something?.not?.existing); // undefined

5. 可选链 ?. 语法总结

可选链 ?. 语法有三种形式:

  1. obj?.prop —— 如果 obj 存在则返回 obj.prop,否则返回 undefined
  2. obj?.[prop] —— 如果 obj 存在则返回 obj[prop],否则返回 undefined
  3. obj?.method() —— 如果 obj 存在则调用 obj.method(),否则返回 undefined

正如我们所看到的,这些语法形式用起来都很简单直接。?. 检查左边部分是否为 null/undefined,如果不是则继续运算。
?. 链使我们能够安全地访问嵌套属性。

七、Symbol

规范规定,JavaScript 中对象的属性只能为 字符串类型 或者 Symbol类型 ,毕竟我们也只见过这两种类型。

1. 概念介绍

ES6引入Symbol作为一种新的原始数据类型,表示独一无二的值,主要是为了防止属性名冲突
ES6之后,JavaScript一共有其中数据类型:SymbolundefinednullBooleanStringNumberObject
简单使用

let leo = Symbol();
typeof leo; // "symbol"

Symbol 支持传入参数作为 Symbol 名,方便代码调试:
**

let leo = Symbol("leo");

2. 注意事项**

  • Symbol函数不能用new,会报错。

由于Symbol是一个原始类型,不是对象,所以不能添加属性,它是类似于字符串的数据类型。

let leo = new Symbol()
// Uncaught TypeError: Symbol is not leo constructor
  • Symbol都是不相等的,即使参数相同
// 没有参数
let leo1 = Symbol();
let leo2 = Symbol();
leo1 === leo2; // false 

// 有参数
let leo1 = Symbol('leo');
let leo2 = Symbol('leo');
leo1 === leo2; // false
  • Symbol不能与其他类型的值计算,会报错。
let leo = Symbol('hello');
leo + " world!";  // 报错
`${leo} world!`;  // 报错
  • Symbol 不能自动转换为字符串,只能显式转换。
let leo = Symbol('hello');
alert(leo); 
// Uncaught TypeError: Cannot convert a Symbol value to a string

String(leo);    // "Symbol(hello)"
leo.toString(); // "Symbol(hello)"
  • Symbol 可以转换为布尔值,但不能转为数值:
let a1 = Symbol();
Boolean(a1);
!a1;        // false
Number(a1); // TypeError
a1 + 1 ;    // TypeError
  • Symbol 属性不参与 for...in/of 循环。
let id = Symbol("id");
let user = {
  name: "Leo",
  age: 30,
  [id]: 123
};

for (let key in user) console.log(key); // name, age (no symbols)

// 使用 Symbol 任务直接访问
console.log( "Direct: " + user[id] );

3. 字面量中使用 Symbol 作为属性名

在对象字面量中使用 Symbol 作为属性名时,需要使用 方括号[] ),如 [leo]: "leo"
好处:防止同名属性,还有防止键被改写或覆盖。

let leo = Symbol();
// 写法1
let user = {};
user[leo] = 'leo';

// 写法2
let user = {
    [leo] : 'leo'
} 

// 写法3
let user = {};
Object.defineProperty(user, leo, {value : 'leo' });

// 3种写法 结果相同
user[leo]; // 'leo'

需要注意 :Symbol作为对象属性名时,不能用点运算符,并且必须放在方括号内。

let leo = Symbol();
let user = {};
// 不能用点运算
user.leo = 'leo';
user[leo] ; // undefined
user['leo'] ; // 'leo'

// 必须放在方括号内
let user = {
    [leo] : function (text){
        console.log(text);
    }
}
user[leo]('leo'); // 'leo'

// 上面等价于 更简洁
let user = {
    [leo](text){
        console.log(text);
    }
}

常常还用于创建一组常量,保证所有值不相等

let user = {};
user.list = {
    AAA: Symbol('Leo'),
    BBB: Symbol('Robin'),
    CCC: Symbol('Pingan')
}

4. 应用:消除魔术字符串

魔术字符串:指代码中多次出现,强耦合的字符串或数值,应该避免,而使用含义清晰的变量代替。

function fun(name){
    if(name == 'leo') {
        console.log('hello');
    }
}
fun('leo');   // 'hello' 为魔术字符串

常使用变量,消除魔术字符串:

let obj = {
    name: 'leo'
};
function fun(name){
    if(name == obj.name){
        console.log('hello');
    }
}
fun(obj.name); // 'hello'

使用Symbol消除强耦合,使得不需关系具体的值:

let obj = {
    name: Symbol()
};
function fun (name){
    if(name == obj.name){
        console.log('hello');
    }
}
fun(obj.name); // 'hello'

5. 属性名遍历

Symbol作为属性名遍历,不出现在for...infor...of循环,也不被Object.keys()Object.getOwnPropertyNames()JSON.stringify()返回。

let leo = Symbol('leo'), robin = Symbol('robin');
let user = {
    [leo]:'18', [robin]:'28'
}
for(let k of Object.values(user)){console.log(k)}
// 无输出

let user = {};
let leo = Symbol('leo');
Object.defineProperty(user, leo, {value: 'hi'});
for(let k in user){
    console.log(k); // 无输出
}
Object.getOwnPropertyNames(user);   // []
Object.getOwnPropertySymbols(user); // [Symbol(leo)]

Object.getOwnPropertySymbols方法返回一个数组,包含当前对象所有用做属性名的Symbol值。

let user = {};
let leo = Symbol('leo');
let pingan = Symbol('pingan');
user[leo] = 'hi leo';
user[pingan] = 'hi pingan';
let obj = Object.getOwnPropertySymbols(user);
obj; //  [Symbol(leo), Symbol(pingan)]

另外可以使用Reflect.ownKeys方法可以返回所有类型的键名,包括常规键名和 Symbol 键名。

let user = {
    [Symbol('leo')]: 1,
    age : 2, 
    address : 3,
}
Reflect.ownKeys(user); // ['age', 'address',Symbol('leo')]

由于Symbol值作为名称的属性不被常规方法遍历获取,因此常用于定义对象的一些非私有,且内部使用的方法。

6. Symbol.for()、Symbol.keyFor()

6.1 Symbol.for()

用于重复使用一个Symbol值,接收一个字符串作为参数,若存在用此参数作为名称的Symbol值,返回这个Symbol,否则新建并返回以这个参数为名称的Symbol值。

let leo = Symbol.for('leo');
let pingan = Symbol.for('leo');
leo === pingan;  // true

Symbol()Symbol.for()区别:

Symbol.for('leo') === Symbol.for('leo'); // true
Symbol('leo') === Symbol('leo');         // false

6.2 Symbol.keyFor()

用于返回一个已使用的Symbol类型的key:

let leo = Symbol.for('leo');
Symbol.keyFor(leo);   //  'leo'

let leo = Symbol('leo');
Symbol.keyFor(leo);   //  undefined

7. 内置的Symbol值

ES6提供11个内置的Symbol值,指向语言内部使用的方法:

7.1 Symbol.hasInstance

当其他对象使用instanceof运算符,判断是否为该对象的实例时,会调用这个方法。比如,foo instanceof Foo在语言内部,实际调用的是Foo[Symbol.hasInstance](foo)

class P {
    [Symbol.hasInstance](a){
        return a instanceof Array;
    }
}
[1, 2, 3] instanceof new P(); // true

P是一个类,new P()会返回一个实例,该实例的Symbol.hasInstance方法,会在进行instanceof运算时自动调用,判断左侧的运算子是否为Array的实例。

7.2 Symbol.isConcatSpreadable

值为布尔值,表示该对象用于Array.prototype.concat()时,是否可以展开。

let a = ['aa','bb'];
['cc','dd'].concat(a, 'ee'); 
// ['cc', 'dd', 'aa', 'bb', 'ee']
a[Symbol.isConcatSpreadable]; // undefined
let b = ['aa','bb']; 
b[Symbol.isConcatSpreadable] = false; 
['cc','dd'].concat(b, 'ee'); 
// ['cc', 'dd',[ 'aa', 'bb'], 'ee']

7.3 Symbol.species

指向一个构造函数,在创建衍生对象时会使用,使用时需要用get取值器。

class P extends Array {
    static get [Symbol.species](){
        return this;
    }
}

解决下面问题:

// 问题:  b应该是 Array 的实例,实际上是 P 的实例
class P extends Array{}
let a = new P(1,2,3);
let b = a.map(x => x);
b instanceof Array; // true
b instanceof P; // true
// 解决:  通过使用 Symbol.species
class P extends Array {
  static get [Symbol.species]() { return Array; }
}
let a = new P();
let b = a.map(x => x);
b instanceof P;     // false
b instanceof Array; // true

7.4 Symbol.match

当执行str.match(myObject),传入的属性存在时会调用,并返回该方法的返回值。

class P {
    [Symbol.match](string){
        return 'hello world'.indexOf(string);
    }
}
'h'.match(new P());   // 0

7.5 Symbol.replace

当该对象被String.prototype.replace方法调用时,会返回该方法的返回值。

let a = {};
a[Symbol.replace] = (...s) => console.log(s);
'Hello'.replace(a , 'World') // ["Hello", "World"]

7.6 Symbol.hasInstance

当该对象被String.prototype.search方法调用时,会返回该方法的返回值。

class P {
    constructor(val) {
        this.val = val;
    }
    [Symbol.search](s){
        return s.indexOf(this.val);
    }
}
'hileo'.search(new P('leo')); // 2

7.7 Symbol.split

当该对象被String.prototype.split方法调用时,会返回该方法的返回值。

// 重新定义了字符串对象的split方法的行为
class P {
    constructor(val) {
        this.val = val;
    }
    [Symbol.split](s) {
        let i = s.indexOf(this.val);
        if(i == -1) return s;
        return [
            s.substr(0, i),
            s.substr(i + this.val.length)
        ]
    }
}
'helloworld'.split(new P('hello')); // ["hello", ""]
'helloworld'.split(new P('world')); // ["", "world"] 
'helloworld'.split(new P('leo'));   // "helloworld"

7.8 Symbol.iterator

对象进行for...of循环时,会调用Symbol.iterator方法,返回该对象的默认遍历器。

class P {
    *[Symbol.interator]() {
        let i = 0;
        while(this[i] !== undefined ) {
            yield this[i];
            ++i;
        }
    }
}
let a = new P();
a[0] = 1;
a[1] = 2;
for (let k of a){
    console.log(k);
}

7.9.Symbol.toPrimitive

该对象被转为原始类型的值时,会调用这个方法,返回该对象对应的原始类型值。调用时,需要接收一个字符串参数,表示当前运算模式,运算模式有:

  • Number : 此时需要转换成数值
  • String : 此时需要转换成字符串
  • Default : 此时可以转换成数值或字符串
let obj = {
  [Symbol.toPrimitive](hint) {
    switch (hint) {
      case 'number':
        return 123;
      case 'string':
        return 'str';
      case 'default':
        return 'default';
      default:
        throw new Error();
     }
   }
};
2 * obj // 246
3 + obj // '3default'
obj == 'default' // true
String(obj) // 'str'

7.10 Symbol.toStringTag

在该对象上面调用Object.prototype.toString方法时,如果这个属性存在,它的返回值会出现在toString方法返回的字符串之中,表示对象的类型。也就是说,这个属性可以用来定制[object Object]或[object Array]object后面的那个字符串。

// 例一
({[Symbol.toStringTag]: 'Foo'}.toString())
// "[object Foo]"
// 例二
class Collection {
  get [Symbol.toStringTag]() {
    return 'xxx';
  }
}
let x = new Collection();
Object.prototype.toString.call(x) // "[object xxx]"

7.11 Symbol.unscopables

该对象指定了使用with关键字时,哪些属性会被with环境排除。

// 没有 unscopables 时
class MyClass {
  foo() { return 1; }
}
var foo = function () { return 2; };
with (MyClass.prototype) {
  foo(); // 1
}
// 有 unscopables 时
class MyClass {
  foo() { return 1; }
  get [Symbol.unscopables]() {
    return { foo: true };
  }
}
var foo = function () { return 2; };
with (MyClass.prototype) {
  foo(); // 2
}

上面代码通过指定Symbol.unscopables属性,使得with语法块不会在当前作用域寻找foo属性,即foo将指向外层作用域的变量。

八、原始值转换

前面复习到字符串、数值、布尔值等的转换,但是没有讲到对象的转换规则,这部分就一起看看:。
需要记住几个规则:

  1. 所有对象在布尔上下文中都为 true ,并且不存在转换为布尔值的操作,只有字符串和数值转换有。
  2. 数值转换发生在对象相减或应用数学函数时。如 Date 对象可以相减,如 date1 - date2 结果为两个时间的差值。
  3. 在字符串转换,通常出现在如 alert(obj) 这种形式。

当然我们可以使用特殊的对象方法,对字符串和数值转换进行微调。下面介绍三个类型(hint)转换情况:

1. object to string

对象到字符串的转换,当我们对期望一个字符串的对象执行操作时,如 “alert”:

// 输出
alert(obj);
// 将对象作为属性键
anotherObj[obj] = 123;

2. object to number

对象到数字的转换,例如当我们进行数学运算时:

// 显式转换
let num = Number(obj);
// 数学运算(除了二进制加法)
let n = +obj; // 一元加法
let delta = date1 - date2;
// 小于/大于的比较
let greater = user1 > user2;

3. object to default

少数情况下,当运算符“不确定”期望值类型时
例如,二进制加法 + 可用于字符串(连接),也可以用于数字(相加),所以字符串和数字这两种类型都可以。因此,当二元加法得到对象类型的参数时,它将依据 "default" 来对其进行转换。
此外,如果对象被用于与字符串、数字或 symbol 进行 == 比较,这时到底应该进行哪种转换也不是很明确,因此使用 "default"

// 二元加法使用默认 hint
let total = obj1 + obj2;
// obj == number 使用默认 hint
if (user == 1) { ... };

4. 类型转换算法

为了进行转换,JavaScript 尝试查找并调用三个对象方法:

  1. 调用 obj[Symbol.toPrimitive](hint) —— 带有 symbol 键 Symbol.toPrimitive(系统 symbol)的方法,如果这个方法存在的话,
  2. 否则,如果 hint 是 "string" —— 尝试 obj.toString()obj.valueOf(),无论哪个存在。
  3. 否则,如果 hint 是 "number""default" —— 尝试 obj.valueOf()obj.toString(),无论哪个存在。

5. Symbol.toPrimitive

详细介绍可阅读《MDN | Symbol.toPrimitive》
Symbol.toPrimitive 是一个内置的 Symbol 值,它是作为对象的函数值属性存在的,当一个对象转换为对应的原始值时,会调用此函数。
简单示例介绍:

let user = {
  name: "Leo",
  money: 9999,

  [Symbol.toPrimitive](hint) {
    console.log(`hint: ${hint}`);
    return hint == "string" ? `{name: "${this.name}"}` : this.money;
  }
};

alert(user);     // 控制台:hint: string 弹框:{name: "John"}
alert(+user);    // 控制台:hint: number 弹框:9999
alert(user + 1); // 控制台:hint: default 弹框:10000

6. toString/valueOf

toString / valueOf 是两个比较早期的实现转换的方法。当没有 Symbol.toPrimitive ,那么 JavaScript 将尝试找到它们,并且按照下面的顺序进行尝试:

  • 对于 “string” hint,toString -> valueOf
  • 其他情况,valueOf -> toString

这两个方法必须返回一个原始值。如果 toStringvalueOf 返回了一个对象,那么返回值会被忽略。默认情况下,普通对象具有 toStringvalueOf 方法:

  • toString 方法返回一个字符串 "[object Object]"
  • valueOf 方法返回对象自身。

简单示例介绍:

const user = {name: "Leo"};

alert(user); // [object Object]
alert(user.valueOf() === user); // true

我们也可以结合 toString / valueOf  实现前面第 5 点介绍的 user 对象:

let user = {
  name: "Leo",
  money: 9999,

  // 对于 hint="string"
  toString() {
    return `{name: "${this.name}"}`;
  },

  // 对于 hint="number" 或 "default"
  valueOf() {
    return this.money;
  }

};

alert(user);     // 控制台:hint: string 弹框:{name: "John"}
alert(+user);    // 控制台:hint: number 弹框:9999
alert(user + 1); // 控制台:hint: default 弹框:10000

总结

本文作为《初中级前端 JavaScript 自测清单》第二部分,介绍的内容以 JavaScript 对象为主,其中有让我眼前一亮的知识点,如 Symbol.toPrimitive 方法。我也希望这个清单能帮助大家自测自己的 JavaScript 水平并查缺补漏,温故知新。

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推荐 https://github.com/pingan8787/Leo_Reading/issues
ES小册 js.pingan8787.com
语雀知识库 Cute-FrontEnd

bg

【原理】深入理解 Webpack HMR

学习时间:2020.06.14

学习章节:《Webpack HMR 原理解析》

了不起的 Webpack HMR 学习指南.png

一、HMR 介绍

Hot Module Replacement(以下简称:HMR 模块热替换)是 Webpack 提供的一个非常有用的功能,它允许在 JavaScript 运行时更新各种模块,而无需完全刷新

Hot Module Replacement (or HMR) is one of the most useful features offered by webpack. It allows all kinds of modules to be updated at runtime without the need for a full refresh.
--《Hot Module Replacement》

当我们修改代码并保存后,Webpack 将对代码重新打包,HMR 会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。

HMR 主要通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面时丢失的应用程序状态;
  • 只更新变更内容,以节省宝贵的开发时间;
  • 调整样式更加快速 - 几乎相当于在浏览器调试器中更改样式。

需要注意:HMR 不适用于生产环境,这意味着它应当只在开发环境使用。

二、HMR 使用方式

在 Webpack 中启用 HMR 功能比较简单:

1. 方式一:使用 devServer

1.1 设置 devServer 选项

只需要在 webpack.config.js 中添加 devServer 选项,并设置 hot 值为 true ,并使用HotModuleReplacementPlugin 和 NamedModulesPlugin (可选)两个 Plugins :

// webpack.config.js

const path = require('path')
const webpack = require('webpack')
module.exports = {
	entry: './index.js',
	output: {
		filename: 'bundle.js',
		path: path.join(__dirname, '/')
	},
+	devServer: {
+		hot: true,   // 启动模块热更新 HMR
+   open: true,  // 开启自动打开浏览器页面
+	},
  plugins: [
+   new webpack.NamedModulesPlugin(),
+   new webpack.HotModuleReplacementPlugin()
  ]
}

1.2 添加 scripts

然后在 package.json 中为 scripts 命令即可:

// package.json

{
  // ...
  "scripts": {
+    "start": "webpack-dev-server"
  },
  // ...
}

2. 方式二、使用命令行参数

另一种是通过添加 --hot 参数来实现。添加 --hot 参数后,devServer 会告诉 Webpack 自动引入 HotModuleReplacementPlugin ,而不需要我们手动引入。

另外常常也搭配 --open 来自动打开浏览器到页面。

这里移除掉前面添加的两个 Plugins :

// webpack.config.js

const path = require('path')
const webpack = require('webpack')
module.exports = {
	// ...
- plugins: [
-   new webpack.NamedModulesPlugin(),
-   new webpack.HotModuleReplacementPlugin()
- ]
}

然后修改 package.json 文件中的 scripts 配置:

// package.json

{
  // ...
  "scripts": {
-    "start": "webpack-dev-server"
+    "start": "webpack-dev-server --hot --open"
  },
  // ...
}

3. 简单示例

基于上述配置,我们简单实现一个场景: index.js 文件中导入 hello.js 模块,当 hello.js 模块发生变化时, index.js 将更新模块。

模块代码如下实现:

// hello.js
export default () => 'hi leo!';

// index.js
import hello from './hello.js'
const div = document.createElement('div');
div.innerHTML = hello();

document.body.appendChild(div);

然后在 index.html 中导入打包后的 JS 文件,并执行 npm start 运行项目:

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
</head>
<body>
	<div>了不起的 Webpack HMR 学习指南</div>
	<script src="bundle.js"></script>
</body>
</html>

4. 实现监听更新

当我们通过 HotModuleReplacementPlugin  插件启用了 HMR,则它的接口将被暴露在全局 module.hot  属性下面。通常,可以先检查这个接口是否可访问,然后再开始使用它。

举个例子,你可以这样 accept  一个更新的模块:

if (module.hot) {
  module.hot.accept('./library.js', function() {
    // 使用更新过的 library 模块执行某些操作...
  })
}

关于 module.hot 更多 API ,可以查看官方文档《Hot Module Replacement API》 。

回到上面示例,我们测试更新模块的功能。

这时我们修改 index.js 代码,来监听 hello.js 模块中的更新:

import hello from './hello.js';
const div = document.createElement('div');
div.innerHTML = hello();
document.body.appendChild(div);

+ if (module.hot) {
+   module.hot.accept('./hello.js', function() {
+     console.log('现在在更新 hello 模块了~');
+     div.innerHTML = hello();
+   })
+ }

然后修改 hello.js 文件内容,测试效果:

- export default () => 'hi leo!';
+ export default () => 'hi leo! hello world';

当我们保存代码时,控制台输出 "现在在更新 hello模块了~" ,并且页面中 "hi leo!" 也更新为 "hi leo! hello world" ,证明我们监听到文件更新了。
image.png

简单 Webpack HMR 使用方式就介绍到这,更多介绍,还请阅读官方文档《Hot Module Replacement》

5. devServer 常用配置和技巧

5.1 常用配置

根据目录结构的不同,contentBaseopenPage 参数要配置合适的值,否则运行时应该不会立刻访问到你的首页。 同时要注意你的 publicPath,静态资源打包后生成的路径是一个需要思考的点,取决于你的目录结构。

devServer: {
  contentBase: path.join(__dirname, 'static'),    // 告诉服务器从哪里提供内容(默认当前工作目录)
  openPage: 'views/index.html',  // 指定默认启动浏览器时打开的页面
  index: 'views/index.html',  // 指定首页位置
  watchContentBase: true, // contentBase下文件变动将reload页面(默认false)
  host: 'localhost', // 默认localhost,想外部可访问用'0.0.0.0'
  port: 8080, // 默认8080
  inline: true, // 可以监控js变化
  hot: true, // 热启动
  open: true, // 启动时自动打开浏览器(指定打开chrome,open: 'Google Chrome')
  compress: true, // 一切服务都启用gzip 压缩
  disableHostCheck: true, // true:不进行host检查
  quiet: false,
  https: false,
  clientLogLevel: 'none',
  stats: { // 设置控制台的提示信息
    chunks: false,
    children: false,
    modules: false,
    entrypoints: false, // 是否输出入口信息
    warnings: false,
    performance: false, // 是否输出webpack建议(如文件体积大小)
  },
  historyApiFallback: {
    disableDotRule: true,
  },
  watchOptions: {
    ignored: /node_modules/, // 略过node_modules目录
  },
  proxy: { // 接口代理(这段配置更推荐:写到package.json,再引入到这里)
    "/api-dev": {
      "target": "http://api.test.xxx.com",
      "secure": false,
      "changeOrigin": true,
      "pathRewrite": { // 将url上的某段重写(例如此处是将 api-dev 替换成了空)
        "^/api-dev": ""
      }
    }
  },
  before(app) { },
}

5.2 技巧1:文件形式输出 dev-server 代码

dev-server 输出的代码通常在内存中,但也可以写入硬盘,产出实体文件:

devServer:{
  writeToDisk: true,
}

通常可以用于代理映射文件调试,编译时会产出许多带 hash 的 js 文件,不带 hash 的文件同样也是实时编译的

5.3 技巧2:默认使用本地 IP 启动服务

有的时候,启动服务时,想要默认使用本地的 ip 地址打开:

devServer:{
  disableHostCheck: true, // true:不进行host检查
  // useLocalIp: true, // 建议不在这里配置
  // host: '0.0.0.0', // 建议不在这里配置
}

同时还需要将 host 配置为 0.0.0.0,这个配置建议在 scripts 命令中追加,而非在配置中写死,否则将来不想要这种方式往回改折腾,取巧一点,配个新命令:

"dev-ip": "yarn run dev --host 0.0.0.0 --useLocalIp"

5.4 技巧3:指定启动的调试域名

有时启动的时候希望是指定的调试域名,例如:local.test.baidu.com

devServer:{
  open: true,
  public: 'local.test.baidu.com:8080', // 需要带上端口
  port: 8080,
}

同时需要将 127.0.0.1 修改为指定的 host,可以借助 iHost 等工具去修改,各个工具大同小异,格式如下:

127.0.0.1 local.test.baidu.com

服务启动后将自动打开 local.test.baidu.com:8080 访问

5.5 技巧4:启动 gzip 压缩

devServer:{
  compress: true,
}

三、HMR 基本原理介绍

从前面介绍中,我们知道:HMR 主要功能是会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面
那么,Webpack 编译源码所产生的文件变化在编译时,替换模块实现在运行时,两者如何联系起来?

带着这两个问题,我们先简单看下 HMR 核心工作流程(简化版):

HMR 工作流程图.png

接下来开始 HMR 工作流程分析:

  1. 当 Webpack(Watchman) 监听到项目中的文件/模块代码发生变化后,将变化通知 Webpack 中的构建工具(Packager)即 HMR Plugin;
  2. 然后经过 HMR Plugin 处理后,将结果发送到应用程序(Application)的运行时框架(HMR Runtime);
  3. 最后由 HMR Runtime 将这些发生变化的文件/模块更新(新增/删除或替换)到模块系统中。

其中,HMR Runtime 是构建工具在编译时注入的,通过统一的 Module ID 将编译时的文件与运行时的模块对应起来,并且对外提供一系列 API 供应用层框架(如 React)调用。

💖注意💖:建议先理解上面这张图的大致流程,在进行后续阅读。放心,我等着大家~😃

四、HMR 完整原理和源码分析

通过上一节内容,我们大概知道 HMR 简单工作流程,那么或许你现在可能还有很多疑惑:文件更新是什么通知 HMR Plugin?HMR Plugin 怎么发送更新到 HMR Runtime?等等问题。

那么接下来我们开始详细结合源码分析整个 HMR 模块热更新流程,首先还是先看流程图,可以先不了解图中方法名称(红色字体黄色背景色部分):

Webpack HMR.png

上图展示了从我们修改代码,到模块热更新完成的一个 HMR 完整工作流程,图中已用红色阿拉伯数字符号将流程标识出来。

要了解上面工作原理,我们先理解图中这几个名称概念:

  • Webpack-dev-server :一个服务器插件,相当于 express 服务器,启动一个 Web 服务,只适用于开发环境;
  • Webpack-dev-middleware :一个 Webpack-dev-server 的中间件,作用简单总结为:通过watch mode,监听资源的变更,然后自动打包。
  • Webpack-hot-middleware :结合 Webpack-dev-middleware 使用的中间件,它可以实现浏览器的无刷新更新,也就是 HMR;

下面一起学习 HMR 整个工作原理吧:

1.监控代码变化,重新编译打包

首先根据 devServer 配置,使用 npm start 将启动 Webpack-dev-server 启动本地服务器并进入 Webpack 的 watch 模式,然后初始化 Webpack-dev-middleware ,在 Webpack-dev-middleware 中通过调用 startWatch() 方法对文件系统进行 watch:

// webpack-dev-server\bin\webpack-dev-server.js
// 1.启动本地服务器 Line 386
server = new Server(compiler, options);

// webpack-dev-server\lib\Server.js
// 2.初始化 Webpack-dev-middleware Line 109
this.middleware = webpackDevMiddleware(compiler, Object.assign({}, options, wdmOptions));

// webpack-dev-middleware\lib\Shared.js
// 3.开始 watch 文件系统 Line 171
startWatch: function() {
	//...
	// start watching
	if(!options.lazy) {
		var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
		context.watching = watching;
	}
	//...
}
share.startWatch();
// ...

startWatch() 方法执行后,便进入 watch 模式,若发现文件中代码发生修改,则根据配置文件对模块重新编译打包

2.保存编译结果

Webpack 与 Webpack-dev-middleware 交互,Webpack-dev-middleware 调用 Webpack 的 API 对代码变化进行监控,并通知 Webpack 将重新编译的代码通过 JavaScript 对象保存在内存中

我们会发现,在 output.path 指定的 dist 目录并没有保存编译结果的文件,这是为什么?

其实, Webpack 将编译结果保存在内存中,因为访问内存中的代码比访问文件系统中的文件快,这样可以减少代码写入文件的开销。

Webpack 能将代码保存到内存中,需要归功于 Webpack-dev-middleware 的 memory-fs 依赖库,它将原本 outputFileSystem  替换成了 MemoryFileSystem  的实例,便实现代码输出到内存中。其中部分源码如下:

// webpack-dev-middleware\lib\Shared.js Line 108

// store our files in memory
var fs;
var isMemoryFs = !compiler.compilers && 
    compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
	fs = compiler.outputFileSystem;
} else {
	fs = compiler.outputFileSystem = new MemoryFileSystem();
}
context.fs = fs;

上述代码先判断 fileSystem 是否是 MemoryFileSystem 的实例,若不是,则用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 JavaScript 对象保存在内存中,当浏览器请求 bundle.js 文件时,devServer 就直接去内存中找到上面保存的 JavaScript 对象并返回给浏览器端。

3.监控文件变化,刷新浏览器

Webpack-dev-server 开始监控文件变化,与第 1 步不同的是,这里并不是监控代码变化重新编译打包。

当我们在配置文件中配置了 devServer.watchContentBase 为 true ,Webpack-dev-server 会监听配置文件夹中静态文件的变化,发生变化时,通知浏览器端对应用进行浏览器刷新,这与 HMR 不一样。

// webpack-dev-server\lib\Server.js
// 1. 读取参数 Line 385
if (options.watchContentBase) { defaultFeatures.push('watchContentBase'); }

// 2. 定义 _watch 方法 Line 697
Server.prototype._watch = function (watchPath) {
	// ...
  const watcher = chokidar.watch(watchPath, options).on('change', () => {
    this.sockWrite(this.sockets, 'content-changed');
  });

  this.contentBaseWatchers.push(watcher);
};

// 3. 执行 _watch() 监听文件变化 Line 339
watchContentBase: () => {
    if (/^(https?:)?\/\//.test(contentBase) || typeof contentBase === 'number') {
        throw new Error('Watching remote files is not supported.');
    } else if (Array.isArray(contentBase)) {
        contentBase.forEach((item) => {
            this._watch(item);
        });
    } else {
        this._watch(contentBase);
    }
}

4.建立 WS,同步编译阶段状态

这一步都是 Webpack-dev-server 中处理,主要通过 sockjs(Webpack-dev-server 的依赖),在 Webpack-dev-server 的浏览器端(Client)和服务器端(Webpack-dev-middleware)之间建立 WebSocket 长连接

然后将 Webpack 编译打包的各个阶段状态信息同步到浏览器端。其中有两个重要步骤:

  • 发送状态

Webpack-dev-server 通过 Webpack API 监听 compile 的 done 事件,当 compile 完成后,Webpack-dev-server 通过 _sendStats 方法将编译后新模块的 hash 值用 socket 发送给浏览器端。

  • 保存状态

浏览器端将_sendStats 发送过来的 hash 保存下来,它将会用到后模块热更新
image.png

// webpack-dev-server\lib\Server.js

// 1. 定义 _sendStats 方法 Line 685
// send stats to a socket or multiple sockets
Server.prototype._sendStats = function (sockets, stats, force) {
  //...
  this.sockWrite(sockets, 'hash', stats.hash);
};

// 2. 监听 done 事件 Line 86
compiler.plugin('done', (stats) => {
  	// 将最新打包文件的 hash 值(stats.hash)作为参数传入 _sendStats()
    this._sendStats(this.sockets, stats.toJson(clientStats));
    this._stats = stats;
});

// webpack-dev-server\client\index.js
// 3. 保存 hash 值 Line 74
var onSocketMsg = {
  // ...
  hash: function hash(_hash) {
    currentHash = _hash;
  },
  // ...
}
socket(socketUrl, onSocketMsg);

5.浏览器端发布消息

hash 消息发送完成后,socket 还会发送一条 ok 的消息告知 Webpack-dev-server,由于客户端(Client)并不请求热更新代码,也不执行热更新模块操作,因此通过 emit 一个 "webpackHotUpdate" 消息,将工作转交回 Webpack。

// webpack-dev-server\client\index.js
// 1. 处理 ok 消息 Line 135
var onSocketMsg = {
  // ...
  ok: function ok() {
      sendMsg('Ok');
      if (useWarningOverlay || useErrorOverlay) overlay.clear();
      if (initial) return initial = false; // eslint-disable-line no-return-assign
      reloadApp();
  },
  // ...
}

// 2. 处理刷新 APP Line 218
function reloadApp() {
  // ...
  if (_hot) {
    // 动态加载 emitter
    var hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash);
    if (typeof self !== 'undefined' && self.window) {
      // broadcast update to window
      self.postMessage('webpackHotUpdate' + currentHash, '*');
    }
  }
  // ...
}

6.传递 hash 到 HMR

Webpack/hot/dev-server 监听浏览器端 webpackHotUpdate 消息,将新模块 hash 值传到客户端 HMR 核心中枢的 HotModuleReplacement.runtime ,并调用 check 方法检测更新,判断是浏览器刷新还是模块热更新
如果是浏览器刷新的话,则没有后续步骤咯~~

// webpack\hot\dev-server.js
// 1.监听 webpackHotUpdate Line 42
var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function(currentHash) {
    lastHash = currentHash;
    if(!upToDate() && module.hot.status() === "idle") {
        log("info", "[HMR] Checking for updates on the server...");
        check();
    }
});

var check = function check() {
    module.hot.check(true).then(function(updatedModules) {
        if(!updatedModules) {
         	  // ...
						window.location.reload();// 浏览器刷新
            return;
        }
        if(!upToDate()) {
            check();
        }
    }).catch(function(err) { /*...*/});
};

// webpack\lib\HotModuleReplacement.runtime.js
// 3.调用 HotModuleReplacement.runtime 定义的 check 方法 Line 167
function hotCheck(apply) {
    if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status");
    hotApplyOnUpdate = apply;
    hotSetStatus("check");
    return hotDownloadManifest(hotRequestTimeout).then(function(update) {
				//...
    });
}

7.检测是否存在更新

当 HotModuleReplacement.runtime 调用 check 方法时,会调用 JsonpMainTemplate.runtime 中的 hotDownloadUpdateChunk (获取最新模块代码)和 hotDownloadManifest (获取是否有更新文件)两个方法,这两个方法的源码,在下一步展开。

// webpack\lib\HotModuleReplacement.runtime.js
// 1.调用 HotModuleReplacement.runtime 定义 hotDownloadUpdateChunk 方法 Line 171
function hotCheck(apply) {
    if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status");
    hotApplyOnUpdate = apply;
    hotSetStatus("check");
    return hotDownloadManifest(hotRequestTimeout).then(function(update) {
				//...
        {
          // hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk
          hotEnsureUpdateChunk(chunkId);
        }
    });
}

其中 hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk

// webpack\lib\HotModuleReplacement.runtime.js Line 215
	function hotEnsureUpdateChunk(chunkId) {
		if(!hotAvailableFilesMap[chunkId]) {
			hotWaitingFilesMap[chunkId] = true;
		} else {
			hotRequestedFilesMap[chunkId] = true;
			hotWaitingFiles++;
			hotDownloadUpdateChunk(chunkId);
		}
	}

8.请求更新最新文件列表

在调用 check 方法时,会先调用 JsonpMainTemplate.runtime 中的 hotDownloadManifest 方法, 通过向服务端发起 AJAX 请求获取是否有更新文件,如果有的话将 mainfest 返回给浏览器端。
image.png
这边涉及一些原生 XMLHttpRequest,就不全部贴出了~

// webpack\lib\JsonpMainTemplate.runtime.js
// hotDownloadManifest 定义 Line 22
function hotDownloadManifest(requestTimeout) {
    return new Promise(function(resolve, reject) {
        try {
            var request = new XMLHttpRequest();
            var requestPath = $require$.p + $hotMainFilename$;
            request.open("GET", requestPath, true);
            request.timeout = requestTimeout;
            request.send(null);
        } catch(err) {
            return reject(err);
        }
        request.onreadystatechange = function() {
            // ...
        };
    });
}

9.请求更新最新模块代码

hotDownloadManifest 方法中,还会执行  hotDownloadUpdateChunk 方法,通过 JSONP 请求最新的模块代码,并将代码返回给 HMR runtime 。
image.png

然后 HMR runtime 会将新代码进一步处理,判断是浏览器刷新还是模块热更新

// webpack\lib\JsonpMainTemplate.runtime.js
// hotDownloadManifest 定义 Line 12
function hotDownloadUpdateChunk(chunkId) {
		// 创建 script 标签,发起 JSONP 请求
    var head = document.getElementsByTagName("head")[0];
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.charset = "utf-8";
    script.src = $require$.p + $hotChunkFilename$;
    $crossOriginLoading$;
    head.appendChild(script);
}

10.更新模块和依赖引用

这一步是整个模块热更新(HMR)的核心步骤,通过 HMR runtime 的 hotApply 方法,移除过期模块和代码,并添加新的模块和代码实现热更新。

hotApply 方法可以看出,模块热替换主要分三个阶段:

  1. 找出过期模块 outdatedModules 和过期依赖 outdatedDependencies
// webpack\lib\HotModuleReplacement.runtime.js
// 找出 outdatedModules 和 outdatedDependencies Line 342
function hotApply() { 
  // ...
  var outdatedDependencies = {};
  var outdatedModules = [];
  function getAffectedStuff(updateModuleId) {
    var outdatedModules = [updateModuleId];
    var outdatedDependencies = {};
    // ...
    return {
        type: "accepted",
        moduleId: updateModuleId,
        outdatedModules: outdatedModules,
        outdatedDependencies: outdatedDependencies
    };
	};
  function addAllToSet(a, b) {
      for (var i = 0; i < b.length; i++) {
          var item = b[i];
          if (a.indexOf(item) < 0)
              a.push(item);
      }
  }
  for(var id in hotUpdate) {
      if(Object.prototype.hasOwnProperty.call(hotUpdate, id)) {
          // ... 省略多余代码
          if(hotUpdate[id]) {
              result = getAffectedStuff(moduleId);
          }
          if(doApply) {
              for(moduleId in result.outdatedDependencies) {
                 // 添加到 outdatedDependencies
                  addAllToSet(outdatedDependencies[moduleId], result.outdatedDependencies[moduleId]);
              }
          }
          if(doDispose) {
              // 添加到 outdatedModules
              addAllToSet(outdatedModules, [result.moduleId]);
              appliedUpdate[moduleId] = warnUnexpectedRequire;
          }
      }
  }
}
  1. 从缓存中删除过期模块、依赖和所有子元素的引用;
// webpack\lib\HotModuleReplacement.runtime.js
// 从缓存中删除过期模块、依赖和所有子元素的引用 Line 442
function hotApply() {
 		// ...
    var idx;
    var queue = outdatedModules.slice();
    while(queue.length > 0) {
        moduleId = queue.pop();
        module = installedModules[moduleId];
        // ...
        // 移除缓存中的模块
        delete installedModules[moduleId];
        // 移除过期依赖中不需要使用的处理方法
        delete outdatedDependencies[moduleId];
        // 移除所有子元素的引用
        for(j = 0; j < module.children.length; j++) {
            var child = installedModules[module.children[j]];
            if(!child) continue;
            idx = child.parents.indexOf(moduleId);
            if(idx >= 0) {
                child.parents.splice(idx, 1);
            }
        }
    } 
		// 从模块子组件中删除过时的依赖项
		var dependency;
		var moduleOutdatedDependencies;
		for(moduleId in outdatedDependencies) {
			if(Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)) {
				module = installedModules[moduleId];
				if(module) {
					moduleOutdatedDependencies = outdatedDependencies[moduleId];
					for(j = 0; j < moduleOutdatedDependencies.length; j++) {
						dependency = moduleOutdatedDependencies[j];
						idx = module.children.indexOf(dependency);
						if(idx >= 0) module.children.splice(idx, 1);
					}
				}
			}
		}
}
  1. 将新模块代码添加到 modules 中,当下次调用 __webpack_require__  (webpack 重写的 require  方法)方法的时候,就是获取到了新的模块代码了。
// webpack\lib\HotModuleReplacement.runtime.js
// 将新模块代码添加到 modules 中 Line 501
function hotApply() {
 		// ...
    for(moduleId in appliedUpdate) {
        if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
            modules[moduleId] = appliedUpdate[moduleId];
        }
    }
}

hotApply 方法执行之后,新代码已经替换旧代码,但是我们业务代码并不知道这些变化,因此需要通过 accept事件通知应用层使用新的模块进行“局部刷新”,我们在业务中是这么使用:

if (module.hot) {
  module.hot.accept('./library.js', function() {
    // 使用更新过的 library 模块执行某些操作...
  })
}

11.热更新错误处理

在热更新过程中,hotApply 过程中可能出现 abort 或者 fail 错误,则热更新退回到刷新浏览器(Browser Reload),整个模块热更新完成。

// webpack\hot\dev-server.js Line 13
module.hot.check(true).then(function (updatedModules) {
    if (!updatedModules) {
        return window.location.reload();
    }
    // ...
}).catch(function (err) {
    var status = module.hot.status();
    if (["abort", "fail"].indexOf(status) >= 0) {
        window.location.reload();
    }
});

五、总结

本文主要​和大家分享 Webpack 的 HMR 使用和实现原理及源码分析,在源码分析中,通过一张“Webpack HMR 工作原理解析”图让大家对 HMR 整个工作流程有所了解,HMR 本身源码内容较多,许多细节之处本文没有完整写出,需要各位读者自己慢慢阅读和理解源码。

参考文章

1.官方文档《Hot Module Replacement》

2.《Webpack HMR 原理解析》

3.《webpack HMR》 

4.《配置 dev-server》 

bg

【原理】深入理解 Webpack 构建流程

最近原创文章回顾:

Webpack 是前端很火的打包工具,它本质上是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 Webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有模块打包成一个或多个 bundle

其实就是:Webpack 是一个 JS 代码打包器。

至于图片、CSS、Less、TS等其他文件,就需要 Webpack 配合 loader 或者 plugin 功能来实现~

了不起的 Webpack 构建流程学习指南.png

一、Webpack 构建流程分析

1. Webpack 构建过程

首先先简单了解下 Webpack 构建过程:

  1. 根据配置,识别入口文件;
  2. 逐层识别模块依赖(包括 Commonjs、AMD、或 ES6 的 import 等,都会被识别和分析);
  3. Webpack 主要工作内容就是分析代码,转换代码,编译代码,最后输出代码;
  4. 输出最后打包后的代码。

2. Webpack 构建原理

看完上面的构建流程的简单介绍,相信你已经简单了解了这个过程,那么接下来开始详细介绍 Webpack 构建原理,包括从启动构建到输出结果一系列过程:

(1)初始化参数

解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。

(2)开始编译

上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。

(3)确定入口

从配置文件( webpack.config.js )中指定的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。

(4)编译模块

递归中根据文件类型loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

(5)完成模块编译并输出

递归完后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk

(6)输出完成

输出所有的 chunk 到文件系统。

注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩覆盖之前的结果

二、手写 Webpack 构建工具

到这里,相信大家对 Webpack 构建流程已经有所了解,但是这还不够,我们再来试着手写 Webpack 构建工具,来将上面文字介绍的内容,应用于实际代码,那么开始吧~

1. 初始化项目

在手写构建工具前,我们先初始化一个项目:

$ yarn init -y

并安装下面四个依赖包:

  1. @babel/parser : 用于分析通过 fs.readFileSync  读取的文件内容,并返回 AST (抽象语法树) ; 
  2. @babel/traverse : 用于遍历 AST, 获取必要的数据;
  3. @babel/core : babel 核心模块,提供 transformFromAst 方法,用于将 AST 转化为浏览器可运行的代码;
  4. @babel/preset-env : 将转换后代码转化成 ES5 代码;
$ yarn add @babel/parser @babel/traverse @babel/core @babel/preset-env

初始化项目目录及文件:
image.png

代码存放在仓库:https://github.com/pingan8787/Leo-JavaScript/tree/master/Cute-Webpack/Write-Webpack

由于本部分核心内容是实现 Webpack 构建工具,所以会从《2. Webpack 构建原理》的“(3)确定入口”步骤开始下面介绍。

大致代码实现流程如下:

webpack构建流程.jpg

从图中可以看出,手写 Webpack 的核心是实现以下三个方法:

  • createAssets : 收集和处理文件的代码;
  • createGraph :根据入口文件,返回所有文件依赖图;
  • bundle : 根据依赖图整个代码并输出;

2. 实现 createAssets 函数

2.1 读取通过入口文件,并转为 AST

首先在 ./src/index 文件中写点简单代码:

// src/index.js

import info from "./info.js";
console.log(info);

实现 createAssets 方法中的 文件读取AST转换 操作:

// leo_webpack.js

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
// 由于 traverse 采用的 ES Module 导出,我们通过 requier 引入的话就加个 .default
const babel = require("@babel/core");

let moduleId = 0;
const createAssets = filename => {
    const content = fs.readFileSync(filename, "utf-8"); // 根据文件名,同步读取文件流
  
  	// 将读取文件流 buffer 转换为 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源码类型
    })
    console.log(ast);
}

createAssets('./src/index.js');

上面代码:
通过 fs.readFileSync() 方法,以同步方式读取指定路径下的文件流,并通过 parser 依赖包提供的 parse() 方法,将读取到的文件流 buffer 转换为浏览器可以认识的代码(AST),AST 输出如下:

image.png

另外需要注意,这里我们声明了一个 moduleId 变量,来区分当前操作的模块。
在这里,不仅将读取到的文件流 buffer 转换为 AST 的同时,也将 ES6 代码转换为 ES5 代码了。

2.2 收集每个模块的依赖

接下来声明 dependencies 变量来保存收集到的文件依赖路径,通过 traverse() 方法遍历 ast,获取每个节点依赖路径,并 pushdependencies 数组中。

// leo_webpack.js

function createAssets(filename){
    // ...
    const dependencies = []; // 用于收集文件依赖的路径

  	// 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });
}

2.3 将 AST 转换为浏览器可运行代码

在收集依赖的同时,我们可以将 AST 代码转换为浏览器可运行代码,这就需要使用到 babel ,这个万能的小家伙,为我们提供了非常好用的 transformFromAstSync() 方法,同步的将 AST 转换为浏览器可运行代码:

// leo_webpack.js

function createAssets(filename){
    // ...
    const { code } = babel.transformFromAstSync(ast,null, {
        presets: ["@babel/preset-env"]
    });
    let id = moduleId++; // 设置当前处理的模块ID
    return {
        id,
        filename,
        code,
        dependencies
    }
}

到这一步,我们在执行 node leo_webpack.js ,输出如下内容,包含了入口文件的路径 filename  、浏览器可执行代码 code 和文件依赖的路径 dependencies 数组:

$ node leo_webpack.js

{ 
  filename: './src/index.js',
  code: '"use strict";\n\nvar _info = _interopRequireDefault(require("./info.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_info["default"]);', 
  dependencies: [ './info.js' ] 
}

2.4 代码小结

// leo_webpack.js

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
// 由于 traverse 采用的 ES Module 导出,我们通过 requier 引入的话就加个 .default
const babel = require("@babel/core");

let moduleId = 0;
function createAssets(filename){
    const content = fs.readFileSync(filename, "utf-8"); // 根据文件名,同步读取文件流
  
  	// 将读取文件流 buffer 转换为 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源码类型
    })
    const dependencies = []; // 用于收集文件依赖的路径

  	// 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });

  	// 通过 AST 将 ES6 代码转换成 ES5 代码
    const { code } = babel.transformFromAstSync(ast,null, {
        presets: ["@babel/preset-env"]
    });
  
    let id = moduleId++; // 设置当前处理的模块ID
    return {
      	id,
        filename,
        code,
        dependencies
    }
}

3. 实现 createGraph 函数

createGraph() 函数中,我们将递归所有依赖模块,循环分析每个依赖模块依赖,生成一份依赖图谱。
为了方便测试,我们补充下 consts.jsinfo.js 文件的代码,增加一些依赖关系:

// src/consts.js
export const company = "平安";

// src/info.js
import { company } from "./consts.js";
export default `你好,${company}`;

接下来开始实现 createGraph() 函数,它需要接收一个入口文件的路径( entry )作为参数:

// leo_webpack.js

function createGraph(entry) {
    const mainAsset = createAssets(entry); // 获取入口文件下的内容
    const queue = [mainAsset]; // 入口文件的结果作为第一项
    for(const asset of queue){
        const dirname = path.dirname(asset.filename);
        asset.mapping = {};
        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径
            const child = createAssets(absolutePath);
            asset.mapping[relativePath] = child.id; // 保存模块ID 
            queue.push(child); // 递归去遍历所有子节点的文件
        })
    }
    return queue;
}

上面代码:

首先通过 createAssets() 函数读取入口文件的内容,并作为依赖关系的队列(依赖图谱) queue 数组的第一项,接着遍历依赖图谱 queue 每一项,再遍历将每一项中的依赖 dependencies 依赖数组,将依赖中的每一项拼接成依赖的绝对路径(absolutePath ),作为 createAssets() 函数调用的参数,递归去遍历所有子节点的文件,并将结果都保存在依赖图谱 queue 中。

注意, mapping 对象是用来保存文件的相对路径和模块 ID 的对应关系,在 mapping 对象中,我们使用依赖文件的相对路径作为 key ,来存储保存模块 ID。

然后我们修改启动函数:

// leo_webpack.js

- const result = createAssets('./src/index.js');
+ const graph = createGraph("./src/index.js");
+ console.log(graph);

这时我们将得到一份包含所有文件依赖关系的依赖图谱:

image.png

这个依赖图谱,包含了所有文件模块的依赖,以及模块的代码内容。下一步只要实现 bundle() 函数,将结果输出即可。

4. 实现 bundle 函数

从前面介绍,我们知道,函数 createGraph() 会返回一个包含每个依赖相关信息(id / filename / code / dependencies)的依赖图谱 queue,这一步就将使用到它了。

bundle() 函数中,接收一个依赖图谱 graph 作为参数,最后输出编译后的结果。

4.1 读取所有模块信息

我们首先声明一个变量 modules,值为字符串类型,然后对参数 graph 进行遍历,将每一项中的 id 属性作为 key ,值为一个数组,包括一个用来执行代码 code 的方法和序列化后的 mapping,最后拼接到 modules 中。

// leo_webpack.js

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
}

上面代码:

modules 中每一项的值中,下标为 0 的元素是个函数,接收三个参数 require / module / exports ,为什么会需要这三个参数呢?

原因是:构建工具无法判断是否支持require / module / exports 这三种模块方法,所以需要自己实现(后面步骤会实现),然后方法内的 code 才能正常执行。

4.2 返回最终结果

接着,我们来实现 bundle() 函数返回值的处理:

// leo_webpack.js

function bundle(graph) {
    //...
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports: {}
                }

                fn(localRequire, module, module.exports);

                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

上面代码:

最终 bundle 函数返回值是一个字符串,包含一个自执行函数(IIFE),其中函数参数是一个对象, keymodulesvalue 为前面拼接好的 modules 字符串,即 {modules: modules字符串}

在这个自执行函数中,实现了 require 方法,接收一个 id 作为参数,在方法内部,分别实现了 localRequire / module / modules.exports 三个方法,并作为参数,传到 modules[id] 中的 fn 方法中,最后初始化 require() 函数(require(0);)。

4.3 代码小结

// leo_webpack.js

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports: {}
                }

                fn(localRequire, module, module.exports);

                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

5. 执行代码

当我们上面方法都实现以后,就开始试试吧:

// leo_webpack.js

const graph = createGraph("./src/index.js");
const result = bundle(graph);
console.log(result)

这时候可以看到终端输出类似这样的代码,是字符串,这里为了方便查看而复制到控制台了:

image.png

这就是打包后的代码咯~

那么如何让这些代码执行呢?用 eval() 方法咯:

// leo_webpack.js

const graph = createGraph("./src/index.js");
const result = bundle(graph);
eval(result);

这时候就能看到控制台输出 你好,平安 。那么我们就完成一个简单的 Webpack 构建工具啦~

能看到这里的朋友,为你点个赞~

三、总结

本文主要介绍了 Webpack 的构建流程和构建原理,并在此基础上,和大家分享了手写 Webpack 的实现过程,希望大家对 Webpack 构建流程能有更深了解,毕竟面试贼喜欢问啦~

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推荐 https://github.com/pingan8787/Leo_Reading/issues
ES小册 js.pingan8787.com
语雀知识库 Cute-FrontEnd

bg

【原理】探索 Snabbdom 模块系统原理

snabbdom-cover

近几年随着 React、Vue 等前端框架不断兴起,Virtual DOM 概念也越来越火,被用到越来越多的框架、库中。Virtual DOM 是基于真实 DOM 的一层抽象,用简单的 JS 对象描述真实 DOM。本文要介绍的 Snabbdom 就是 Virtual DOM 的一种简单实现,并且 Vue 的 Virtual DOM 也参考了 Snabbdom 实现方式。

对于想要深入学习 Vue Virtual DOM 的朋友,建议先学习 Snabbdom,对理解 Vue 会很有帮助,并且其核心代码 200 多行。

本文挑选 Snabbdom 模块系统作为主要核心点介绍,其他内容可以查阅官方文档《Snabbdom》

snabbdom-cover

一、Snabbdom 是什么

Snabbdom 是一个专注于简单性、模块化、强大特性和性能的虚拟 DOM 库。其中有几个核心特性:

  1. 核心代码 200 行,并且提供丰富的测试用例;
  2. 拥有强大模块系统,并且支持模块拓展和灵活组合;
  3. 在每个 VNode 和全局模块上,都有丰富的钩子,可以在 Diff 和 Patch 阶段使用。

接下来从一个简单示例来体验一下 Snabbdom。

1. 快速上手

安装 Snabbdom:

npm install snabbdom -D

接着新建 index.html,设置入口元素:

<div id="app"></div>

然后新建 demo1.js 文件,并使用 Snabbdom 提供的函数:

// demo1.js
import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

const patch = init([])
let vnode = h('div#app', 'Hello Leo')
const app = document.getElementById('app')
patch(app, vnode)

这样就实现一个简单示例,在浏览器打开 index.html,页面将显示 “Hello Leo” 文本。
img-1.png

接下来,我会以 snabbdom-demo 项目作为学习示例,从简单示例到模块系统使用的示例,深入学习和分析 Snabbdom 源码,重点分析 Snabbdom 模块系统。

二、Snabbdom-demo 分析

Snabbdom-demo 项目中的三个演示代码,为我们展示如何从简单到深入 Snabbdom。
首先克隆仓库并安装:

$ git clone https://github.com/zyycode/snabbdom-demo.git
$ npm install

虽然本项目没有 README.md 文件,但项目目录比较直观,我们可以轻松的从 src 目录找到这三个示例代码的文件:

  • 01-basicusage.js
  • 02-basicusage.js
  • 03-modules.js -> 本文核心介绍

接着在 index.html 中引入想要学习的代码文件,默认 <script src="./src/01-basicusage.js"></script>  ,通过 package.json 可知启动命令并启动项目:

$ npm run dev

1. 简单示例分析

当我们要研究一个库或框架等比较复杂的项目,可以通过官方提供的简单示例代码进行分析,我们这里选择该项目中最简单的 01-basicusage.js 代码进行分析,其代码如下:

// src/01-basicusage.js

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

const patch = init([])

let vnode = h('div#container.cls', 'Hello World')
const app = document.getElementById('app') // 入口元素

const oldVNode = patch(app, vnode)

// 假设时刻
vnode = h('div', 'Hello Snabbdom')
patch(oldVNode, vnode)

运行项目以后,可以看到页面展示了“Hello Snabbdom”文本,这里你会觉得奇怪,前面的 “Hello World” 文本去哪了

img-2.png

原因很简单,我们把 demo 中的下面两行代码注释后,页面便显示文本是 “Hello World”:

vnode = h('div', 'Hello Snabbdom')
patch(oldVNode, vnode)

这里我们可以猜测 patch() 函数可以将 VNode 渲染到页面。更进一步可以理解为,这边第一个执行 patch() 函数为首次渲染,第二次执行 patch() 函数为更新操作

img-3.png

2. VNode 介绍

这里可能会有小伙伴疑惑,示例中的 VNode 是什么?这里简单解释下:

VNode,该对象用于描述节点的信息,它的全称是虚拟节点(virtual node)。与 “虚拟节点” 相关联的另一个概念是 “虚拟 DOM”,它是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。“虚拟 DOM” 由 VNode 组成的。
—— 全栈修仙之路 《Vue 3.0 进阶之 VNode 探秘》

其实 VNode 就是一个 JS 对象,在 Snabbdom 中是这么定义 VNode 的类型:

export interface VNode {
  sel: string | undefined; // selector的缩写
  data: VNodeData | undefined; // 下面VNodeData接口的内容
  children: Array<VNode | string> | undefined; // 子节点
  elm: Node | undefined; // element的缩写,存储了真实的HTMLElement
  text: string | undefined; // 如果是文本节点,则存储text
  key: Key | undefined; // 节点的key,在做列表时很有用
}

export interface VNodeData {
  props?: Props
  attrs?: Attrs
  class?: Classes
  style?: VNodeStyle
  dataset?: Dataset
  on?: On
  hero?: Hero
  attachData?: AttachData
  hook?: Hooks
  key?: Key
  ns?: string // for SVGs
  fn?: () => VNode // for thunks
  args?: any[] // for thunks
  [key: string]: any // for any other 3rd party module
}

在 VNode 对象中含描述节点选择器 sel 字段、节点数据 data 字段、节点所包含的子节点 children 字段等。

在这个 demo 中,我们似乎并没有看到模块系统相关的代码,没事,因为这是最简单的示例,下一节会详细介绍。

我们在学习一个函数时,可以重点了解该函数的“入参”和“出参”,大致就能判断该函数的作用。

从这个 demo 主要执行过程可以看出,主要用到有三个函数: init() / patch() / h() ,它们到底做什么用的呢?我们分析一下 Snabbdom 源码中这三个函数的入参和出参情况:

3. init() 函数分析

init() 函数被定义在 package/init.ts 文件中:

// node_modules/snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
	// 省略其他代码
}

其参数类型如下:

function init(modules: Array<Partial<Module>>, domApi?: DOMAPI): (oldVnode: VNode | Element, vnode: VNode) => VNode

export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}>
  
export interface DOMAPI {
  createElement: (tagName: any) => HTMLElement
  createElementNS: (namespaceURI: string, qualifiedName: string) => Element
  createTextNode: (text: string) => Text
  createComment: (text: string) => Comment
  insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void
  removeChild: (node: Node, child: Node) => void
  appendChild: (node: Node, child: Node) => void
  parentNode: (node: Node) => Node | null
  nextSibling: (node: Node) => Node | null
  tagName: (elm: Element) => string
  setTextContent: (node: Node, text: string | null) => void
  getTextContent: (node: Node) => string | null
  isElement: (node: Node) => node is Element
  isText: (node: Node) => node is Text
  isComment: (node: Node) => node is Comment
}

init() 函数接收一个模块数组 modules 和可选的 domApi 对象作为参数,返回一个函数,即 patch() 函数。
domApi 对象的接口包含了很多 DOM 操作的方法。
这里的 modules 参数本文将重点介绍。

4. patch() 函数分析

init() 函数返回了一个 patch() 函数,其类型为:

// node_modules/snabbdom/src/package/init.ts

patch(oldVnode: VNode | Element, vnode: VNode) => VNode

patch() 函数接收两个 VNode 对象作为参数,并返回一个新 VNode。

5. h() 函数分析

h() 函数被定义在 package/h.ts 文件中:

// node_modules/snabbdom/src/package/h.ts

export function h(sel: string): VNode
export function h(sel: string, data: VNodeData | null): VNode
export function h(sel: string, children: VNodeChildren): VNode
export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode{
	// 省略其他代码
}

h() 函数接收多种参数,其中必须有一个 sel 参数,作用是将节点内容挂载到该容器中,并返回一个新 VNode。

6. 小结

通过前面介绍,我们在回过头看看这个 demo 的代码,大致调用流程如下:

img-4.png

三、深入 Snabbdom 模块系统

学习完前面这些基础知识后,我们已经知道 Snabbdom 使用方式,并且知道其中三个核心方法入参出参情况和大致作用,接下来开始看本文核心 Snabbdom 模块系统。

1. Modules 介绍

Snabbdom 模块系统是 Snabbdom 提供的一套可拓展可灵活组合的模块系统,用来为 Snabbdom 提供操作 VNode 时的各种模块支持,如我们组建需要处理 style 则引入对应的 styleModule,需要处理事件,则引入 eventListenersModule 既可,这样就达到灵活组合,可以支持按需引入的效果。

Snabbdom 模块系统的特点可以概括为:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。

当然 Snabbdom 模块系统还有其他内置模块:

模块名称 模块功能 示例代码
attributesModule 为 DOM 元素设置属性,在属性添加和更新时使用 setAttribute 方法。 h('a', { attrs: { href: '/foo' } }, 'Go to Foo')
classModule 用来动态设置和切换 DOM 元素上的 class 名称。 h('a', { class: { active: true, selected: false } }, 'Toggle')
datasetModule 为 DOM 元素设置自定义数据属性(data- *)。然后可以使用 HTMLElement.dataset 属性访问它们。 h('button', { dataset: { action: 'reset' } }, 'Reset')
eventListenersModule 为 DOM 元素绑定事件监听器。 h('div', { on: { click: clickHandler } })
propsModule 为 DOM 元素设置属性,如果同时使用 attributesModule,则会被 attributesModule 覆盖。 h('a', { props: { href: '/foo' } }, 'Go to Foo')
styleModule 为 DOM 元素设置 CSS 属性。 h('span', {style: { color: '#c0ffee'}}, 'Say my name')

2. Hooks 介绍

Hooks 也称钩子,是 DOM 节点生命周期的一种方法。Snabbdom 提供丰富的钩子选择。模块既使用钩子来扩展 Snabbdom,也在普通代码中使用钩子,用来在 DOM 节点生命周期中执行任意代码。

这里大致介绍一下所有的 Hooks:

钩子名称 触发时机 回调参数
pre patch 阶段开始。 none
init 已添加一个 VNode。 vnode
create 基于 VNode 创建了一个 DOM 元素。 emptyVnode, vnode
insert 一个元素已添加到 DOM 元素中。 vnode
prepatch 一个元素即将进入 patch 阶段。 oldVnode, vnode
update 一个元素开始更新。 oldVnode, vnode
postpatch 一个元素完成 patch 阶段。 oldVnode, vnode
destroy 一个元素直接或间接被删除。 vnode
remove 一个元素直接从 DOM 元素中删除。 vnode, removeCallback
post patch 阶段结束。 none

模块中可以使用这些钩子:precreateupdatedestroyremovepost
单个元素可以使用这些钩子:initcreateinsertprepatchupdatepostpatchdestroyremove

Snabbdom 是这么定义钩子的:

// snabbdom/src/package/hooks.ts

export type PreHook = () => any
export type InitHook = (vNode: VNode) => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type InsertHook = (vNode: VNode) => any
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

export interface Hooks {
  pre?: PreHook
  init?: InitHook
  create?: CreateHook
  insert?: InsertHook
  prepatch?: PrePatchHook
  update?: UpdateHook
  postpatch?: PostPatchHook
  destroy?: DestroyHook
  remove?: RemoveHook
  post?: PostHook
}

接下来我们通过 03-modules.js 文件的示例代码,我们需要样式处理事件操作,因此引入这两个模块,并进行灵活组合

// src/03-modules.js

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

// 1. 导入模块
import { styleModule } from 'snabbdom/src/package/modules/style'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'

// 2. 注册模块
const patch = init([ styleModule, eventListenersModule ])

// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {
  style: { backgroundColor: '#4fc08d', color: '#35495d' },
  on: { click: eventHandler }
}, [
  h('h1', 'Hello Snabbdom'),
  h('p', 'This is p tag')
])

function eventHandler() {
  console.log('clicked.')
}

const app = document.getElementById('app')
patch(app, vnode)

上面代码中,引入了 styleModule 和 eventListenersModule 两个模块,并且作为参数组合,传入 init() 函数中。
此时我们可以看到页面上显示的内容已经有包含样式,并且点击事件也能正常输出日志 'clicked.'

img-5.png

这里我们看下 styleModule 模块源码,把代码精简一下:

// snabbdom/src/package/modules/style.ts

function updateStyle (oldVnode: VNode, vnode: VNode): void {
	// 省略其他代码
}

function forceReflow () {
  // 省略其他代码
}

function applyDestroyStyle (vnode: VNode): void {
  // 省略其他代码
}

function applyRemoveStyle (vnode: VNode, rm: () => void): void {
  // 省略其他代码
}

export const styleModule: Module = {
  pre: forceReflow,
  create: updateStyle,
  update: updateStyle,
  destroy: applyDestroyStyle,
  remove: applyRemoveStyle
}

在看看 eventListenersModule 模块源码:

// snabbdom/src/package/modules/eventlisteners.ts

function updateEventListeners (oldVnode: VNode, vnode?: VNode): void {
	// 省略其他代码
}

export const eventListenersModule: Module = {
  create: updateEventListeners,
  update: updateEventListeners,
  destroy: updateEventListeners
}

明显可以看出,两个模块返回的都是个对象,并且每个属性为一种钩子,如 pre/create 等,值为对应的处理函数,每个处理函数有统一的入参。

继续看下 styleModule 中,样式是如何绑定上去的。这里分析它的 updateStyle 方法,因为元素创建(create 钩子)和元素更新(update 钩子)阶段都是通过这个方法处理:

// snabbdom/src/package/modules/style.ts

function updateStyle (oldVnode: VNode, vnode: VNode): void {
  var cur: any
  var name: string
  var elm = vnode.elm
  var oldStyle = (oldVnode.data as VNodeData).style
  var style = (vnode.data as VNodeData).style

  if (!oldStyle && !style) return
  if (oldStyle === style) return
  
  // 1. 设置新旧 style 默认值
  oldStyle = oldStyle || {}
  style = style || {}
  var oldHasDel = 'delayed' in oldStyle

  // 2. 比较新旧 style
  for (name in oldStyle) {
    if (!style[name]) {
      if (name[0] === '-' && name[1] === '-') {
        (elm as any).style.removeProperty(name)
      } else {
        (elm as any).style[name] = ''
      }
    }
  }
  for (name in style) {
    cur = style[name]
    if (name === 'delayed' && style.delayed) {
      // 省略部分代码
    } else if (name !== 'remove' && cur !== oldStyle[name]) {
      if (name[0] === '-' && name[1] === '-') {
        (elm as any).style.setProperty(name, cur)
      } else {
        // 3. 设置新 style 到元素
        (elm as any).style[name] = cur
      }
    }
  }
}

3. init() 分析

接着我们看下 init() 函数内部如何处理这些 Module。

首先在 init.ts 文件中,可以看到声明了默认支持的 Hooks 钩子列表:

// snabbdom/src/package/init.ts

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']

接着看 hooks 是如何使用的:

// snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {  // 创建 cbs 对象,用于收集 module 中的 hook
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: []
  }
	// 收集 module 中的 hook,并保存在 cbs 中
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      const hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        (cbs[hooks[i]] as any[]).push(hook)
      }
    }
  }
	// 省略其他代码,稍后介绍
}

上面代码中,创建 hooks 变量用来声明默认支持的 Hooks 钩子,在 init() 函数中,创建 cbs 对象,通过两层循环,保存每个 module 中的 hook 函数到 cbs 对象的指定钩子中。

通过断点可以看到这是 demo 中,cbs 对象是下面这个样子:

img-6.png

这里 cbs 对象收集了每个 module 中的 Hooks 处理函数,保存到对应 Hooks 数组中。比如这里的 create 钩子中保存了 updateStyle 函数和 updateEventListeners 函数。

img-7.png

到这里, init() 函数已经保存好所有 module 的 Hooks 处理函数,接下来就要看看 init() 函数返回的 patch() 函数,这里面将用到前面保存好的 cbs 对象。

4. patch() 分析

init() 函数中最终返回一个 patch() 函数,这边形成一个闭包,闭包里面可以使用到 init() 函数作用域定义的变量和方法,因此在 patch() 函数中能使用 cbs 对象。

patch() 函数会在不同时机点(可以参照前面的 Hooks 介绍),遍历 cbs 对象中不同 Hooks 处理函数列表。

// snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
	// 省略其他代码
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()  // [Hooks]遍历 pre Hooks 处理函数列表

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode) // 当 oldVnode 参数不是 VNode 则创建一个空的 VNode
    }

    if (sameVnode(oldVnode, vnode)) {  // 当两个 VNode 为同一个 VNode,则进行比较和更新
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      createElm(vnode, insertedVnodeQueue) // 当两个 VNode 不同,则创建新元素

      if (parent !== null) {  // 当该 oldVnode 有父节点,则插入该节点,然后移除原来节点
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()  // [Hooks]遍历 post Hooks 处理函数列表
    return vnode
  }
}

patchVnode() 函数定义如下:

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // 省略其他代码
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)  // [Hooks]遍历 update Hooks 处理函数列表
    }
  }

createVnode() 函数定义如下:

  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // 省略其他代码
    const sel = vnode.sel
    if (sel === '!') {
      // 省略其他代码
    } else if (sel !== undefined) {
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)  // [Hooks]遍历 create Hooks 处理函数列表
      const hook = vnode.data!.hook
    }
    return vnode.elm
  }

removeNodes() 函数定义如下:

  function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void {
    // 省略其他代码
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (ch != null) {
        rm = createRmCb(ch.elm!, listeners)
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // [Hooks]遍历 remove Hooks 处理函数列表
      }
    }
  }

这部分代码跳转较多,总结一下这个过程,如下图:

img-8.png

四、自定义 Snabbdom 模块

前面我们介绍了 Snabbdom 模块系统是如何收集 Hooks 并保存下来,然后在不同时机点执行不同的 Hooks。

在 Snabbdom 中,所有模块独立在 src/package/modules 下,使用的时候可以灵活组合,也方便做解耦和跨平台,并且所有 Module 返回的对象中每个 Hooks 类型如下:

// snabbdom/src/package/init.ts

export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}>

// snabbdom/src/package/hooks.ts
export type PreHook = () => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

因此,如果开发者需要自定义模块,只需实现不同 Hooks 并导出即可。

接下来我们实现一个简单的模块 replaceTagModule,用来将节点文本自动过滤掉 HTML 标签

1. 初始化代码

考虑到方便调试,我们直接在 node_modules/snabbdom/src/package/modules/ 目录中新建 replaceTag.ts 文件,然后写个最简单的 demo 框架:

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

const replaceTagPre = () => {
    console.log("run replaceTagPre!")
}

const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => {
    console.log("run updateReplaceTag!", oldVnode, vnode)
}

const removeReplaceTag = (vnode: VNode): void => {
    console.log("run removeReplaceTag!", vnode)
}

export const replaceTagModule: Module = {
    pre: replaceTagPre,
    create: updateReplaceTag,
    update: updateReplaceTag,
    remove: removeReplaceTag
}

接下来引入到 03-modules.js 代码中,并简化下代码:

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

// 1. 导入模块
import { styleModule } from 'snabbdom/src/package/modules/style'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'
import { replaceTagModule } from 'snabbdom/src/package/modules/replaceTag';

// 2. 注册模块
const patch = init([
  styleModule,
  eventListenersModule,
  replaceTagModule
])

// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', '<h1>Hello Leo</h1>')

const app = document.getElementById('app')
const oldVNode = patch(app, vnode)

let newVNode = h('div', '<div>Hello Leo</div>')

patch(oldVNode, newVNode)

刷新浏览器,就可以看到 replaceTagModule 的每个钩子都被正常执行:

img-9.png

2. 实现 updateReplaceTag() 函数

我们删除掉多余代码,接下来实现 updateReplaceTag() 函数,当 vnode 创建和更新时,都会调用该方法。

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

const regFunction = str => str && str.replace(/\<|\>|\//g, "");

const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => {
    const oldVnodeReplace = regFunction(oldVnode.text);
    const vnodeReplace = regFunction(vnode.text);
    if(oldVnodeReplace === vnodeReplace) return;
    vnode.text = vnodeReplace;
}

export const replaceTagModule: Module = {
    create: updateReplaceTag,
    update: updateReplaceTag,
}
  

updateReplaceTag() 函数中,比较新旧 vnode 的文本内容是否一致,如果一致则直接返回,否则将新的 vnode 的替换后的文本设置到 vnode 的 text 属性,完成更新。

其中有个细节:

vnode.text = vnodeReplace;

这里直接对 vnode.text 进行赋值,页面上的内容也随之发生变化。这是因为 vnode 是个响应式对象,通过调用其 setter 方法,会触发响应式更新,这样就实现页面内容更新。

于是我们看到页面内容中的 HTML 标签被清空了。

img-10.png

3. 小结

这个小节中,我们实现一个简单的 replaceTagModule 模块,体验了一下 Snabbdom 模块灵活组合的特点,当我们需要自定义某些模块时,便可以按照 Snabbdom 的模块开发方式,开发自定义模块,然后通过 Snabbdom 的 init() 函数注入模块即可。

我们再回顾一下 Snabbdom 模块系统特点:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。

五、通用模块生命周期模型

下面我将前面 Snabbdom 的模块系统,抽象为一个通用模块生命周期模型,其中包含三个核心层:

  1. 模块定义层

在本层可以按照模块开发规范,自定义各种模块。

  1. 模块应用层

一般是在业务开发层或组件层中,用来导入模块。

  1. 模块初始化层

一般是在开发的模块系统的插件中,提供初始化函数(init 函数),执行初始化函数会遍历每个 Hooks,并执行对应处理函数列表的每个函数。

抽象后的模型如下:

image.png

在使用 Module 的时候就可以灵活组合搭配使用啦,在模块初始化层,就会做好调用。

六、总结

本文主要以 Snabbdom-demo 仓库为学习示例,学习了 Snabbdom 运行流程和 Snabbdom 模块系统的运行流程,还通过手写一个简单的 Snabbdom 模块,带大家领略一下 Snabbdom 模块的魅力,最后为大家总结了一个通用模块插件模型。

大家好好掌握 Snabbdom 对理解 Vue 会很有帮助。

【原理】探索 React 合成事件

React 是一个 Facebook 开源的,用于构建用户界面的 JavaScript 库。

React 目的在于解决:构建随着时间数据不断变化的大规模应用程序。
其中 React 合成事件是较为重要的知识点,阅读完本文,你将收获:

  1. 合成事件的概念和作用;
  2. 合成事件与原生事件的 3 个区别;
  3. 合成事件与原生事件的执行顺序;
  4. 合成事件的事件池;
  5. 合成事件 4 个常见问题。

接下来和我一起开始学习吧~

一、概念介绍

React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器。它根据 W3C 规范 来定义合成事件,兼容所有浏览器,拥有与浏览器原生事件相同的接口。
看个简单示例:

const button = <button onClick={handleClick}>Leo 按钮</button>

在 React 中,所有事件都是合成的,不是原生 DOM 事件,但可以通过 e.nativeEvent 属性获取 DOM 事件。

const handleClick = (e) => console.log(e.nativeEvent);;
const button = <button onClick={handleClick}>Leo 按钮</button>

学习一个新知识的时候,一定要知道为什么会出现这个技术。
那么 React 为什么使用合成事件?其主要有三个目的:

  1. 进行浏览器兼容,实现更好的跨平台

React 采用的是顶层事件代理机制,能够保证冒泡一致性,可以跨浏览器执行。React 提供的合成事件用来抹平不同浏览器事件对象之间的差异,将不同平台事件模拟合成事件。

  1. 避免垃圾回收

事件对象可能会被频繁创建和回收,因此 React 引入事件池,在事件池中获取或释放事件对象。即 React 事件对象不会被释放掉,而是存放进一个数组中,当事件触发,就从这个数组中弹出,避免频繁地去创建和销毁(垃圾回收)

  1. 方便事件统一管理和事务机制

本文不介绍源码啦,对具体实现的源码有兴趣的朋友可以查阅:《React SyntheticEvent》

二、原生事件回顾

在开始介绍 React 合成事件之前,我们先简单回顾 JavaScript 原生事件中几个重要知识点:
Native-Event.png

1. 事件捕获

当某个元素触发某个事件(如 onclick ),顶层对象 document 就会发出一个事件流,随着 DOM 树的节点向目标元素节点流去,直到到达事件真正发生的目标元素。在这个过程中,事件相应的监听函数是不会被触发的。

2. 事件目标

当到达目标元素之后,执行目标元素该事件相应的处理函数。如果没有绑定监听函数,那就不执行。

3. 事件冒泡

从目标元素开始,往顶层元素传播。途中如果有节点绑定了相应的事件处理函数,这些函数都会被触发一次。如果想阻止事件起泡,可以使用 e.stopPropagation() 或者 e.cancelBubble=true(IE)来阻止事件的冒泡传播。

4. 事件委托/事件代理

简单理解就是将一个响应事件委托到另一个元素
当子节点被点击时,click 事件向上冒泡,父节点捕获到事件后,我们判断是否为所需的节点,然后进行处理。其优点在于减少内存消耗和动态绑定事件

二、合成事件与原生事件区别

React 事件与原生事件很相似,但不完全相同。这里列举几个常见区别:

1. 事件名称命名方式不同

原生事件命名为纯小写(onclick, onblur),而 React 事件命名采用小驼峰式(camelCase),如 onClick 等:

// 原生事件绑定方式
<button onclick="handleClick()">Leo 按钮命名</button>
      
// React 合成事件绑定方式
const button = <button onClick={handleClick}>Leo 按钮命名</button>

2. 事件处理函数写法不同

原生事件中事件处理函数为字符串,在 React JSX 语法中,传入一个函数作为事件处理函数。

// 原生事件 事件处理函数写法
<button onclick="handleClick()">Leo 按钮命名</button>
      
// React 合成事件 事件处理函数写法
const button = <button onClick={handleClick}>Leo 按钮命名</button>

3. 阻止默认行为方式不同

在原生事件中,可以通过返回 false 方式来阻止默认行为,但是在 React 中,需要显式使用 preventDefault() 方法来阻止。
这里以阻止 <a> 标签默认打开新页面为例,介绍两种事件区别:

// 原生事件阻止默认行为方式
<a href="https://www.pingan8787.com" 
  onclick="console.log('Leo 阻止原生事件~'); return false"
>
  Leo 阻止原生事件
</a>

// React 事件阻止默认行为方式
const handleClick = e => {
  e.preventDefault();
  console.log('Leo 阻止原生事件~');
}
const clickElement = <a href="https://www.pingan8787.com" onClick={handleClick}>
  Leo 阻止原生事件
</a>

4. 小结

小结前面几点区别:

原生事件 React 事件
事件名称命名方式 名称全部小写
(onclick, onblur)
名称采用小驼峰
(onClick, onBlur)
事件处理函数语法 字符串 函数
阻止默认行为方式 事件返回 false 使用 e.preventDefault() 方法

Native-Event-VS-Synthetic-Event.png

三、React 事件与原生事件执行顺序

在 React 中,“合成事件”会以事件委托(Event Delegation)方式绑定在组件最上层,并在组件卸载(unmount)阶段自动销毁绑定的事件。这里我们手写一个简单示例来观察 React 事件和原生事件的执行顺序:

class App extends React.Component<any, any> {
  parentRef: any;
  childRef: any;
  constructor(props: any) {
    super(props);
    this.parentRef = React.createRef();
    this.childRef = React.createRef();
  }
  componentDidMount() {
    console.log("React componentDidMount!");
    this.parentRef.current?.addEventListener("click", () => {
      console.log("原生事件:父元素 DOM 事件监听!");
    });
    this.childRef.current?.addEventListener("click", () => {
      console.log("原生事件:子元素 DOM 事件监听!");
    });
    document.addEventListener("click", (e) => {
      console.log("原生事件:document DOM 事件监听!");
    });
  }
  parentClickFun = () => {
    console.log("React 事件:父元素事件监听!");
  };
  childClickFun = () => {
    console.log("React 事件:子元素事件监听!");
  };
  render() {
    return (
      <div ref={this.parentRef} onClick={this.parentClickFun}>
        <div ref={this.childRef} onClick={this.childClickFun}>
          分析事件执行顺序
        </div>
      </div>
    );
  }
}
export default App;

触发事件后,可以看到控制台输出:

原生事件:子元素 DOM 事件监听! 
原生事件:父元素 DOM 事件监听! 
React 事件:子元素事件监听! 
React 事件:父元素事件监听! 
原生事件:document DOM 事件监听! 

通过上面流程,我们可以理解:

  • React 所有事件都挂载在 document 对象上;
  • 当真实 DOM 元素触发事件,会冒泡到 document 对象后,再处理 React 事件;
  • 所以会先执行原生事件,然后处理 React 事件;
  • 最后真正执行 document 上挂载的事件。

Native-Event-And-Synthetic-Event.png

四、合成事件的事件池

1. 事件池介绍

合成事件对象池,是 React 事件系统提供的一种性能优化方式合成事件对象在事件池统一管理不同类型的合成事件具有不同的事件池

  • 当事件池未满时,React 创建新的事件对象,派发给组件。
  • 当事件池装满时,React 从事件池中复用事件对象,派发给组件。

关于“事件池是如何工作”的问题,可以看看下面图片:

Synthetic-Event-Loop.png

(图片来自:ReactDeveloper https://juejin.cn/post/6844903862285893639

2. 事件池分析(React 16 版本)

React 事件池仅支持在 React 16 及更早版本中,在 React 17 已经不使用事件池
下面以 React 16 版本为例:

function handleChange(e) {
  console.log("原始数据:", e.target)
  setTimeout(() => {
    console.log("定时任务 e.target:", e.target); // null
    console.log("定时任务:e:", e); 
  }, 100);
}
function App() {
  return (
    <div className="App">
      <button onClick={handleChange}>测试事件池</button>
    </div>
  );
}

export default App;

可以看到输出:
Synthetic-Event-React16.png

在 React 16 及之前的版本,合成事件对象的事件处理函数全部被调用之后,所有属性都会被置为 null 。这时,如果我们需要在事件处理函数运行之后获取事件对象的属性,可以使用 React 提供的 e.persist() 方法,保留所有属性:

// 只修改 handleChange 方法,其他不变
function handleChange(e) {
  // 只增加 persist() 执行
  e.persist();
  
  console.log("原始数据:", e.target)
  setTimeout(() => {
    console.log("定时任务 e.target:", e.target); // null
    console.log("定时任务:e:", e); 
  }, 100);
}

再看下结果:

Synthetic-Event-React17.png

3. 事件池分析(React 17 版本)

由于 Web 端的 React 17 不使用事件池,所有不会存在上述“所有属性都会被置为 null”的问题。

五、常见问题

1. React 事件中 this 指向问题

在 React 中,JSX 回调函数中的 this 经常会出问题,在 Class 中方法不会默认绑定 this,就会出现下面情况, this.funName 值为 undefined :

class App extends React.Component<any, any> {
  childClickFun = () => {
    console.log("React 事件");
  };
  clickFun() {
    console.log("React this 指向问题", this.childClickFun); // undefined
  }
  render() {
    return (
        <div onClick={this.clickFun}>React this 指向问题</div>
    );
  }
}
export default App;

我们有 2 种方式解决这个问题:

  1. 使用 bind 方法绑定 this :
class App extends React.Component<any, any> {
  constructor(props: any) {
    super(props);
    this.clickFun = this.clickFun.bind(this);
  }
  
  // 省略其他代码
}
export default App;
  1. 将需要使用 this 的方法改写为使用箭头函数定义:
class App extends React.Component<any, any> {
  clickFun = () => {
    console.log("React this 指向问题", this.childClickFun); // undefined
  }
  
  // 省略其他代码
}
export default App;

或者在回调函数中使用箭头函数

class App extends React.Component<any, any> {
  // 省略其他代码
  clickFun() {
    console.log("React this 指向问题", this.childClickFun); // undefined
  }
  render() {
    return (
        <div onClick={() => this.clickFun()}>React this 指向问题</div>
    );
  }
}
export default App;

2. 向事件传递参数问题

经常在遍历列表时,需要向事件传递额外参数,如 id 等,来指定需要操作的数据,在 React 中,可以使用 2 种方式向事件传参:

const List = [1,2,3,4];
class App extends React.Component<any, any> {
  // 省略其他代码
  clickFun (id) {console.log('当前点击:', id)}
  render() {
    return (
        <div>
        	<h1>第一种:通过 bind 绑定 this 传参</h1>
        	{
          	List.map(item => <div onClick={this.clickFun.bind(this, item)}>按钮:{item}</div>)
          }
        	<h1>第二种:通过箭头函数绑定 this 传参</h1>
        	{
          	List.map(item => <div onClick={() => this.clickFun(item)}>按钮:{item}</div>)
          }
        </div>
    );
  }
}
export default App;

这两种方式是等价的:

  • 第一种通过 Function.prototype.bind 实现;
  • 第二种通过箭头函数实现。

3. 合成事件阻止冒泡

官网文档描述了:

从 v0.14 开始,事件处理器返回 false 时,不再阻止事件传递。你可以酌情手动调用 e.stopPropagation() 或 e.preventDefault() 作为替代方案。

也就是说,在 React 合成事件中,需要阻止冒泡时,可以使用 e.stopPropagation()e.preventDefault() 方法来解决,另外还可以使用 e.nativeEvent.stopImmediatePropagation() 方法解决。

3.1 e.stopPropagation

对于开发者来说,更希望使用 e.stopPropagation() 方法来阻止当前 DOM 事件冒泡,但事实上,从前两节介绍的执行顺序可知,e.stopPropagation() 只能阻止合成事件间冒泡,即下层的合成事件,不会冒泡到上层的合成事件。事件本身还都是在 document 上执行。所以最多只能阻止 document 事件不能再冒泡到 window 上。

class App extends React.Component<any, any> {
  parentRef: any;
  childRef: any;
  constructor(props: any) {
    super(props);
    this.parentRef = React.createRef();
  }
  componentDidMount() {
    this.parentRef.current?.addEventListener("click", () => {
      console.log("阻止原生事件冒泡~");
    });
    document.addEventListener("click", (e) => {
      console.log("原生事件:document DOM 事件监听!");
    });
  }
  parentClickFun = (e: any) => {
    e.stopPropagation();
    console.log("阻止合成事件冒泡~");
  };
  render() {
    return (
      <div ref={this.parentRef} onClick={this.parentClickFun}>
        点击测试“合成事件和原生事件是否可以混用”
      </div>
    );
  }
}
export default App;

输出结果:

阻止原生事件冒泡~ 
阻止合成事件冒泡~ 

3.2 e.nativeEvent.stopImmediatePropagation

该方法可以阻止监听同一事件的其他事件监听器被调用
在 React 中,一个组件只能绑定一个同类型的事件监听器,当重复定义时,后面的监听器会覆盖之前的。
事实上 nativeEvent 的 stopImmediatePropagation只能阻止绑定在 document 上的事件监听器。而合成事件上的 e.nativeEvent.stopImmediatePropagation()阻止合成事件不会冒泡到 document 上

举一个实际案例:实现点击空白处关闭菜单的功能:
当菜单打开时,在 document 上动态注册事件,用来关闭菜单。

  • 点击菜单内部,由于不冒泡,会正常执行菜单点击。
  • 点击菜单外部,执行document上事件,关闭菜单。

在菜单关闭的一刻,在 document 上移除该事件,这样就不会重复执行该事件,浪费性能,也可以在 window 上注册事件,这样可以避开 document。
**

4. 合成事件和原生事件是否可以混用

合成事件和原生事件最好不要混用
原生事件中如果执行了stopPropagation方法,则会导致其他React事件失效。因为所有元素的事件将无法冒泡到document上。
通过前面介绍的两者事件执行顺序来看,所有的 React 事件都将无法被注册。通过代码一起看看:

class App extends React.Component<any, any> {
  parentRef: any;
  childRef: any;
  constructor(props: any) {
    super(props);
    this.parentRef = React.createRef();
  }
  componentDidMount() {
    this.parentRef.current?.addEventListener("click", (e: any) => {
    	e.stopPropagation();
      console.log("阻止原生事件冒泡~");
    });
    document.addEventListener("click", (e) => {
      console.log("原生事件:document DOM 事件监听!");
    });
  }
  parentClickFun = (e: any) => {
    console.log("阻止合成事件冒泡~");
  };
  render() {
    return (
      <div ref={this.parentRef} onClick={this.parentClickFun}>
        点击测试“合成事件和原生事件是否可以混用”
      </div>
    );
  }
}
export default App;

输出结果:

阻止原生事件冒泡~ 

好了,本文就写到这里,建议大家可以再回去看下官方文档《合成事件》《事件处理》章节理解,有兴趣的朋友也可以阅读源码《React SyntheticEvent.js》

总结

最后在回顾下本文学习目标:

  1. 合成事件的概念和作用;
  2. 合成事件与原生事件的 3 个区别;
  3. 合成事件与原生事件的执行顺序;
  4. 合成事件的事件池;
  5. 合成事件 4 个常见问题。

你是否都清楚了?欢迎一起讨论学习。

参考文章

1.《事件处理与合成事件(react)》
2.官方文档《合成事件》《事件处理》
3.《React合成事件和DOM原生事件混用须知》
4.《React 合成事件系统之事件池》

【总结】TypeScript 设计模式之发布-订阅模式

前言

在之前两篇自测清单中,和大家分享了很多 JavaScript 基础知识,大家可以一起再回顾下~

本文是我在我们团队内部“现代 JavaScript 突击队”分享的一篇内容,第二期学习内容为“设计模式”系列,我会将我负责分享的知识整理成文章输出,希望能够和大家一起温故知新!

现代 JavaScript 突击队”学习总结:

  1. 《初中级前端 JavaScript 自测清单 - 1》
  2. 《初中级前端 JavaScript 自测清单 - 2》
  3. 《TypeScript 设计模式之观察者模式》
  4. 《TypeScript语法总结+项目(Vue.js+TS)实战》

一、模式介绍

1. 生活场景

最近刚毕业的学生 Leo 准备开始租房了,他来到房产中介,跟中介描述了自己的租房需求,开开心心回家了。第二天,中介的小哥哥小姐姐为 Leo 列出符他需求的房间,并打电话约他一起看房了,最后 Leo 选中一套满意的房间,高高兴兴过去签合同,准备开始新生活~

还有个大佬 Paul,准备将手中 10 套房出租出去,于是他来到房产中介,在中介那边提供了自己要出租的房间信息,沟通好手续费,开开心心回家了。第二天,Paul 接到中介的好消息,房子租出去了,于是他高高兴兴过去签合同,开始收房租了~

发布-订阅模式(简介).png

上面场景有个需要特别注意的地方:

  • 租户在租房过程中,不知道房间具体房东是谁,到后面签合同才知道;
  • 房东在出租过程中,不知道房间具体租户是谁,到后面签合同才知道;

这两点其实就是后面要介绍的 发布-订阅模式 的一个核心特点。

2. 概念介绍

软件架构中,发布-订阅模式是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。

发布-订阅是消息队列范式的兄弟,通常是更大的面向消息中间件系统的一部分。大多数消息系统在API中同时支持消息队列模型和发布/订阅模型,例如Java消息服务(JMS)。

这种模式提供了更大的网络可扩展性和更动态的网络拓扑,同时也降低了对发布者和发布数据的结构修改的灵活性。

二、 观察者模式 vs 发布-订阅模式

看完上面概念,有没有觉得与观察者模式很像?
但其实两者还是有差异的,接下来一起看看。

1. 概念对比

我们分别为通过两种实际生活场景来介绍这两种模式:

  • 观察者模式:如微信中 顾客-微商 关系;
  • 发布-订阅模式:如淘宝购物中 顾客-淘宝-商家 关系。

这两种场景的过程分别是这样:

1.1 观察者模式

观察者模式(微商与顾客).png
观察者模式中,消费顾客关注(如加微信好友)自己有兴趣的微商,微商就会私聊发自己在卖的产品给消费顾客。
这个过程中,消费顾客相当于观察者(Observer),微商相当于观察目标(Subject)。

1.2 发布-订阅模式

接下来看看 发布-订阅模式 :

发布-订阅模式(淘宝与顾客) .png
发布-订阅模式 中,消费顾客通过淘宝搜索自己关注的产品,商家通过淘宝发布商品,当消费顾客在淘宝搜索的产品,已经有商家发布,则淘宝会将对应商品推荐给消费顾客。
这个过程中,消费顾客相当于订阅者,淘宝相当于事件总线,商家相当于发布者。

2. 流程对比

观察者模式和发布-订阅模式区别(流程图).png

3. 小结

所以可以看出,观察者模式发布-订阅模式差别在于有没有一个**的事件总线。如果有,我们就可以认为这是个发布-订阅模式。如果没有,那么就可以认为是观察者模式。因为其实它们都实现了一个关键的功能:发布事件-订阅事件并触发事件

三、模式特点

对比完观察者模式发布-订阅模式后,我们大致理解发布-订阅模式是什么了。接着总结下该模式的特点:

1. 模式组成

在发布-订阅模式中,通常包含以下角色:

  • 发布者:Publisher
  • 事件总线:Event Channel
  • 订阅者:Subscriber

2. UML 类图

发布-订阅模式(UML).jpg

3. 优点

  1. 松耦合(Independence)

发布-订阅模式可以将众多需要通信的子系统(Subsystem)解耦,每个子系统独立管理。而且即使部分子系统取消订阅,也不会影响事件总线的整体管理。
发布-订阅模式中每个应用程序都可以专注于其核心功能,而事件总线负责将消息路由到每个订阅者手里。

  1. 高伸缩性(Scalability)

发布-订阅模式增加了系统的可伸缩性,提高了发布者的响应能力。原因是发布者(Publisher)可以快速地向输入通道发送一条消息,然后返回到其核心处理职责,而不必等待子系统处理完成。然后事件总线负责确保把消息传递到每个订阅者(Subscriber)手里。

  1. 高可靠性(Reliability)

发布-订阅模式提高了可靠性。异步的消息传递有助于应用程序在增加的负载下继续平稳运行,并且可以更有效地处理间歇性故障。

  1. 灵活性(Flexibility)

你不需要关心不同的组件是如何组合在一起的,只要他们共同遵守一份协议即可。
发布-订阅模式允许延迟处理或者按计划的处理。例如当系统负载大的时候,订阅者可以等到非高峰时间才接收消息,或者根据特定的计划处理消息。

4. 缺点

  1. 在创建订阅者本身会消耗内存,但当订阅消息后,没有进行发布,而订阅者会一直保存在内存中,占用内存;
  2. 创建订阅者需要消耗一定的时间和内存。如果过度使用的话,反而使代码不好理解及代码不好维护。

四、使用场景

如果我们项目中很少使用到订阅者,或者与子系统实时交互较少,则不适合 发布-订阅模式 。
在以下情况下可以考虑使用此模式:

  1. 应用程序需要向大量消费者广播信息。例如微信订阅号就是一个消费者量庞大的广播平台。
  2. 应用程序需要与一个或多个独立开发的应用程序或服务通信,这些应用程序或服务可能使用不同的平台、编程语言和通信协议。
  3. 应用程序可以向消费者发送信息,而不需要消费者的实时响应。

五、实战示例

1. 简单示例

  1. 定义发布者接口(Publisher)、事件总线接口(EventChannel)和订阅者接口(Subscriber):
interface Publisher<T> {
  subscriber: string;
  data: T;
}

interface EventChannel<T>  {
  on  : (subscriber: string, callback: () => void) => void;
  off : (subscriber: string, callback: () => void) => void;
  emit: (subscriber: string, data: T) => void;
}

interface Subscriber {
  subscriber: string;
  callback: () => void;
}

// 方便后面使用
interface PublishData {
  [key: string]: string;
}
  1. 实现具体发布者类(ConcretePublisher):
class ConcretePublisher<T> implements Publisher<T> {
  public subscriber: string = "";
  public data: T; 
  constructor(subscriber: string, data: T) {
    this.subscriber = subscriber;
    this.data = data;
  }
}
  1. 实现具体事件总线类(ConcreteEventChannel):
class ConcreteEventChannel<T> implements EventChannel<T> {
  // 初始化订阅者对象
  private subjects: { [key: string]: Function[] } = {};

  // 实现添加订阅事件
  public on(subscriber: string, callback: () => void): void {
    console.log(`收到订阅信息,订阅事件:${subscriber}`);
    if (!this.subjects[subscriber]) {
      this.subjects[subscriber] = [];
    }
    this.subjects[subscriber].push(callback);
  };

  // 实现取消订阅事件
  public off(subscriber: string, callback: () => void): void {
    console.log(`收到取消订阅请求,需要取消的订阅事件:${subscriber}`);
    if (callback === null) {
      this.subjects[subscriber] = [];
    } else {
      const index: number = this.subjects[subscriber].indexOf(callback);
      ~index && this.subjects[subscriber].splice(index, 1);
    }
  };
  
  // 实现发布订阅事件
  public emit (subscriber: string, data: T): void {
    console.log(`收到发布者信息,执行订阅事件:${subscriber}`);
    this.subjects[subscriber].forEach(item => item(data));
  };
}
  1. 实现具体订阅者类(ConcreteSubscriber):
class ConcreteSubscriber implements Subscriber {
  public subscriber: string = "";
  constructor(subscriber: string, callback: () => void) {
    this.subscriber = subscriber;
    this.callback = callback;
  }
  public callback(): void { };
}
  1. 运行示例代码:
interface Publisher<T> {
  subscriber: string;
  data: T;
}

interface EventChannel<T>  {
  on  : (subscriber: string, callback: () => void) => void;
  off : (subscriber: string, callback: () => void) => void;
  emit: (subscriber: string, data: T) => void;
}

interface Subscriber {
  subscriber: string;
  callback: () => void;
}

interface PublishData {
  [key: string]: string;
}

class ConcreteEventChannel<T> implements EventChannel<T> {
  // 初始化订阅者对象
  private subjects: { [key: string]: Function[] } = {};

  // 实现添加订阅事件
  public on(subscriber: string, callback: () => void): void {
    console.log(`收到订阅信息,订阅事件:${subscriber}`);
    if (!this.subjects[subscriber]) {
      this.subjects[subscriber] = [];
    }
    this.subjects[subscriber].push(callback);
  };

  // 实现取消订阅事件
  public off(subscriber: string, callback: () => void): void {
    console.log(`收到取消订阅请求,需要取消的订阅事件:${subscriber}`);
    if (callback === null) {
      this.subjects[subscriber] = [];
    } else {
      const index: number = this.subjects[subscriber].indexOf(callback);
      ~index && this.subjects[subscriber].splice(index, 1);
    }
  };
  
  // 实现发布订阅事件
  public emit (subscriber: string, data: T): void {
    console.log(`收到发布者信息,执行订阅事件:${subscriber}`);
    this.subjects[subscriber].forEach(item => item(data));
  };
}

class ConcretePublisher<T> implements Publisher<T> {
  public subscriber: string = "";
  public data: T; 
  constructor(subscriber: string, data: T) {
    this.subscriber = subscriber;
    this.data = data;
  }
}

class ConcreteSubscriber implements Subscriber {
  public subscriber: string = "";
  constructor(subscriber: string, callback: () => void) {
    this.subscriber = subscriber;
    this.callback = callback;
  }
  public callback(): void { };
}


/* 运行示例 */
const pingan8787 = new ConcreteSubscriber(
  "running",
  () => { 
    console.log("订阅者 pingan8787 订阅事件成功!执行回调~");
  }
);

const leo = new ConcreteSubscriber(
  "swimming",
  () => { 
    console.log("订阅者 leo 订阅事件成功!执行回调~");
  }
);

const lisa = new ConcreteSubscriber(
  "swimming",
  () => { 
    console.log("订阅者 lisa 订阅事件成功!执行回调~");
  }
);

const pual = new ConcretePublisher<PublishData>(
  "swimming",
  {message: "pual 发布消息~"}
);

const eventBus = new ConcreteEventChannel<PublishData>();
eventBus.on(pingan8787.subscriber, pingan8787.callback);
eventBus.on(leo.subscriber, leo.callback);
eventBus.on(lisa.subscriber, lisa.callback);

// 发布者 pual 发布 "swimming"相关的事件
eventBus.emit(pual.subscriber, pual.data); 
eventBus.off (lisa.subscriber, lisa.callback);
eventBus.emit(pual.subscriber, pual.data);

/*
输出结果:
[LOG]: 收到订阅信息,订阅事件:running
[LOG]: 收到订阅信息,订阅事件:swimming
[LOG]: 收到订阅信息,订阅事件:swimming
[LOG]: 收到发布者信息,执行订阅事件:swimming 
[LOG]: 订阅者 leo 订阅事件成功!执行回调~ 
[LOG]: 订阅者 lisa 订阅事件成功!执行回调~ 
[LOG]: 收到取消订阅请求,需要取消的订阅事件:swimming 
[LOG]: 收到发布者信息,执行订阅事件:swimming 
[LOG]: 订阅者 leo 订阅事件成功!执行回调~ 
*/

完整代码如下:

interface Publisher {
  subscriber: string;
  data: any;
}

interface EventChannel {
  on  : (subscriber: string, callback: () => void) => void;
  off : (subscriber: string, callback: () => void) => void;
  emit: (subscriber: string, data: any) => void;
}

interface Subscriber {
  subscriber: string;
  callback: () => void;
}

class ConcreteEventChannel implements EventChannel {
  // 初始化订阅者对象
  private subjects: { [key: string]: Function[] } = {};

  // 实现添加订阅事件
  public on(subscriber: string, callback: () => void): void {
    console.log(`收到订阅信息,订阅事件:${subscriber}`);
    if (!this.subjects[subscriber]) {
      this.subjects[subscriber] = [];
    }
    this.subjects[subscriber].push(callback);
  };

  // 实现取消订阅事件
  public off(subscriber: string, callback: () => void): void {
    console.log(`收到取消订阅请求,需要取消的订阅事件:${subscriber}`);
    if (callback === null) {
      this.subjects[subscriber] = [];
    } else {
      const index: number = this.subjects[subscriber].indexOf(callback);
      ~index && this.subjects[subscriber].splice(index, 1);
    }
  };
  
  // 实现发布订阅事件
  public emit (subscriber: string, data = null): void {
    console.log(`收到发布者信息,执行订阅事件:${subscriber}`);
    this.subjects[subscriber].forEach(item => item(data));
  };
}

class ConcretePublisher implements Publisher {
  public subscriber: string = "";
  public data: any; 
  constructor(subscriber: string, data: any) {
    this.subscriber = subscriber;
    this.data = data;
  }
}

class ConcreteSubscriber implements Subscriber {
  public subscriber: string = "";
  constructor(subscriber: string, callback: () => void) {
    this.subscriber = subscriber;
    this.callback = callback;
  }
  public callback(): void { };
}


/* 运行示例 */
const pingan8787 = new ConcreteSubscriber(
  "running",
  () => { 
    console.log("订阅者 pingan8787 订阅事件成功!执行回调~");
  }
);

const leo = new ConcreteSubscriber(
  "swimming",
  () => { 
    console.log("订阅者 leo 订阅事件成功!执行回调~");
  }
);

const lisa = new ConcreteSubscriber(
  "swimming",
  () => { 
    console.log("订阅者 lisa 订阅事件成功!执行回调~");
  }
);

const pual = new ConcretePublisher(
  "swimming",
  {message: "pual 发布消息~"}
);

const eventBus = new ConcreteEventChannel();
eventBus.on(pingan8787.subscriber, pingan8787.callback);
eventBus.on(leo.subscriber, leo.callback);
eventBus.on(lisa.subscriber, lisa.callback);

// 发布者 pual 发布 "swimming"相关的事件
eventBus.emit(pual.subscriber, pual.data); 
eventBus.off (lisa.subscriber, lisa.callback);
eventBus.emit(pual.subscriber, pual.data);

/*
输出结果:
[LOG]: 收到订阅信息,订阅事件:running
[LOG]: 收到订阅信息,订阅事件:swimming
[LOG]: 收到订阅信息,订阅事件:swimming
[LOG]: 收到发布者信息,执行订阅事件:swimming 
[LOG]: 订阅者 leo 订阅事件成功!执行回调~ 
[LOG]: 订阅者 lisa 订阅事件成功!执行回调~ 
[LOG]: 收到取消订阅请求,需要取消的订阅事件:swimming 
[LOG]: 收到发布者信息,执行订阅事件:swimming 
[LOG]: 订阅者 leo 订阅事件成功!执行回调~ 
*/

2. Vue.js 使用示例

参考文章:《Vue事件总线(EventBus)使用详细介绍》 。

2.1 创建 event bus

在 Vue.js 中创建 EventBus 有两种方式:

  1. 手动实现,导出 Vue 实例化的结果。
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue();
  1. 直接在项目中的 main.js全局挂载 Vue 实例化的结果。
// main.js
Vue.prototype.$EventBus = new Vue()

2.2 发送事件

假设你有两个Vue页面需要通信: A 和 B ,A页面按钮上绑定了点击事件,发送一则消息,通知 B 页面。

<!-- A.vue -->
<template>
    <button @click="sendMsg()">-</button>
</template>

<script> 
import { EventBus } from "../event-bus.js";
export default {
  methods: {
    sendMsg() {
      EventBus.$emit("aMsg", '来自A页面的消息');
    }
  }
}; 
</script>

2.3 接收事件

B 页面中接收消息,并展示内容到页面上。

<!-- IncrementCount.vue -->
<template>
  <p>{{msg}}</p>
</template>

<script> 
import { 
  EventBus 
} from "../event-bus.js";
export default {
  data(){
    return {
      msg: ''
    }
  },
  mounted() {
    EventBus.$on("aMsg", (msg) => {
      // A发送来的消息
      this.msg = msg;
    });
  }
};
</script>

同理可以从 B 页面往 A 页面发送消息,使用下面方法:

// 发送消息
EventBus.$emit(channel: string, callback(payload1,))

// 监听接收消息
EventBus.$on(channel: string, callback(payload1,))

2.4 移除事件监听者

使用 EventBus.$off('aMsg') 来移除应用内所有对此某个事件的监听。或者直接用 EventBus.$off() 来移除所有事件频道,不需要添加任何参数 。

import { 
  eventBus 
} from './event-bus.js'
EventBus.$off('aMsg', {})

六、总结

观察者模式和发布-订阅模式的差别在于事件总线,如果有则是发布-订阅模式,反之为观察者模式。所以在实现发布-订阅模式,关键在于实现这个事件总线,在某个特定时间触发某个特定事件,从而触发监听这个特定事件的组件进行相应操作的功能。发布-订阅模式在很多时候非常有用。

参考文章

1.《发布/订阅》 
2.《观察者模式VS订阅发布模式》 

【原理】探索 Vue3 响应式原理

image.png

Vue 3 中的响应式原理可谓是非常之重要,通过学习 Vue3 的响应式原理,不仅能让我们学习到 Vue.js 的一些设计模式和**,还能帮助我们提高项目开发效率和代码调试能力

在这之前,我也写了一篇《探索 Vue.js 响应式原理》 ,主要介绍 Vue 2 响应式的原理,这篇补上 Vue 3 的。

于是最近在 Vue Mastery 上重新学习 Vue3 Reactivity 的知识,这次收获更大。本文将带大家从头开始学习如何实现简单版 Vue 3 响应式,帮助大家了解其核心,后面阅读 Vue 3 响应式相关的源码能够更加得心应手。

一、Vue 3 响应式使用

1. Vue 3 中的使用

当我们在学习 Vue 3 的时候,可以通过一个简单示例,看看什么是 Vue 3 中的响应式:

<!-- HTML 内容 -->
<div id="app">
    <div>Price: {{price}}</div>
    <div>Total: {{price * quantity}}</div>
    <div>getTotal: {{getTotal}}</div>
</div>
const app = Vue.createApp({ // ① 创建 APP 实例
    data() {
        return {
            price: 10,
            quantity: 2
        }
    },
    computed: {
        getTotal() {
            return this.price * this.quantity * 1.1
        }
    }
})
app.mount('#app')  // ② 挂载 APP 实例

通过创建 APP 实例和挂载 APP 实例即可,这时可以看到页面中分别显示对应数值:
image.png

当我们修改 price 或 quantity 值的时候,页面上引用它们的地方,内容也能正常展示变化后的结果。这时,我们会好奇为何数据发生变化后,相关的数据也会跟着变化,那么我们接着往下看。

2. 实现单个值的响应式

在普通 JS 代码执行中,并不会有响应式变化,比如在控制台执行下面代码:

let price = 10, quantity = 2;
const total = price * quantity;
console.log(`total: ${total}`); // total: 20
price = 20;
console.log(`total: ${total}`); // total: 20

从这可以看出,在修改 price 变量的值后, total 的值并没有发生改变。

那么如何修改上面代码,让 total 能够自动更新呢?我们其实可以将修改 total 值的方法保存起来,等到与 total 值相关的变量(如 price 或 quantity 变量的值)发生变化时,触发该方法,更新 total 即可。我们可以这么实现:

let price = 10, quantity = 2, total = 0;
const dep = new Set(); // ① 
const effect = () => { total = price * quantity };
const track = () => { dep.add(effect) };  // ②
const trigger = () => { dep.forEach( effect => effect() )};  // ③

track();
console.log(`total: ${total}`); // total: 0
trigger();
console.log(`total: ${total}`); // total: 20
price = 20;
trigger();
console.log(`total: ${total}`); // total: 40

上面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Set 类型的 dep 变量,用来存放需要执行的副作用( effect 函数),这边是修改 total 值的方法;

② 创建 track() 函数,用来将需要执行的副作用保存到 dep 变量中(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中的所有副作用;

在每次修改 pricequantity 后,调用 trigger() 函数执行所有副作用后, total 值将自动更新为最新值。
image.png

(图片来源:Vue Mastery)

3. 实现单个对象的响应式

通常,我们的对象具有多个属性,并且每个属性都需要自己的 dep。我们如何存储这些?比如:

let product = { price: 10, quantity: 2 };

从前面介绍我们知道,我们将所有副作用保存在一个 Set 集合中,而该集合不会有重复项,这里我们引入一个 Map 类型集合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为前面保存副作用的 Set 集合(如: dep 对象),大致结构如下图:

image.png
(图片来源:Vue Mastery)

实现代码:

let product = { price: 10, quantity: 2 }, total = 0;
const depsMap = new Map(); // ① 
const effect = () => { total = product.price * product.quantity };
const track = key => {     // ②
	let dep = depsMap.get(key);
  if(!dep) {
		depsMap.set(key, (dep = new Set()));
  }
	dep.add(effect);
}

const trigger = key => {  // ③
	let dep = depsMap.get(key);
  if(dep) {
		dep.forEach( effect => effect() );
  }
};

track('price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger('price');
console.log(`total: ${total}`); // total: 40

上面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Map 类型的 depsMap 变量,用来保存每个需要响应式变化的对象属性(key 为对象的属性, value 为前面 Set 集合);

② 创建 track() 函数,用来将需要执行的副作用保存到 depsMap 变量中对应的对象属性下(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

4. 实现多个对象的响应式

如果我们有多个响应式数据,比如同时需要观察对象 a 和对象 b  的数据,那么又要如何跟踪每个响应变化的对象?

这里我们引入一个 WeakMap 类型的对象,将需要观察的对象作为 key ,值为前面用来保存对象属性的 Map 变量。代码如下:

let product = { price: 10, quantity: 2 }, total = 0;
const targetMap = new WeakMap();     // ① 初始化 targetMap,保存观察对象
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => {     // ② 收集依赖
  let depsMap = targetMap.get(target);
  if(!depsMap){
  	targetMap.set(target, (depsMap = new Map()));
  }
	let dep = depsMap.get(key);
  if(!dep) {
		depsMap.set(key, (dep = new Set()));
  }
	dep.add(effect);
}

const trigger = (target, key) => {  // ③ 执行指定对象的指定属性的所有副作用
  const depsMap = targetMap.get(target);
  if(!depsMap) return;
	let dep = depsMap.get(key);
  if(dep) {
		dep.forEach( effect => effect() );
  }
};

track(product, 'price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger(product, 'price');
console.log(`total: ${total}`); // total: 40

上面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 WeakMap 类型的 targetMap 变量,用来要观察每个响应式对象;

② 创建 track() 函数,用来将需要执行的副作用保存到指定对象( target )的依赖中(也称收集副作用);

③ 创建 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

大致流程如下图:

image.png
(图片来源:Vue Mastery)

二、Proxy 和 Reflect

在上一节内容中,介绍了如何在数据发生变化后,自动更新数据,但存在的问题是,每次需要手动通过触发 track() 函数搜集依赖,通过 trigger() 函数执行所有副作用,达到数据更新目的。

这一节将来解决这个问题,实现这两个函数自动调用。

1. 如何实现自动操作

这里我们引入 JS 对象访问器的概念,解决办法如下:

  • 在读取(GET 操作)数据时,自动执行 track() 函数自动收集依赖;
  • 在修改(SET 操作)数据时,自动执行 trigger() 函数执行所有副作用;

那么如何拦截 GET 和 SET 操作?接下来看看 Vue2 和 Vue3 是如何实现的:

需要注意的是:Vue3 使用的 ProxyReflect API 并不支持 IE。

Object.defineProperty() 函数这边就不多做介绍,可以阅读文档,下文将主要介绍 ProxyReflect API。

2. 如何使用 Reflect

通常我们有三种方法读取一个对象的属性:

  1. 使用 . 操作符:leo.name
  2. 使用 []leo['name']
  3. 使用 Reflect API: Reflect.get(leo, 'name')

这三种方式输出结果相同。

3. 如何使用 Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。语法如下:

const p = new Proxy(target, handler)

参数如下:

  • target : 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  • handler : 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

我们通过官方文档,体验一下 Proxy API

let product = { price: 10, quantity: 2 };
let proxiedProduct = new Proxy(product, {
	get(target, key){
  	console.log('正在读取的数据:',key);
    return target[key];
  }
})
console.log(proxiedProduct.price); 
// 正在读取的数据: price
// 10

这样就保证我们每次在读取 proxiedProduct.price 都会执行到其中代理的 get 处理函数。其过程如下:

image.png
(图片来源:Vue Mastery)

然后结合 Reflect 使用,只需修改 get 函数:

	get(target, key, receiver){
  	console.log('正在读取的数据:',key);
    return Reflect.get(target, key, receiver);
  }

输出结果还是一样。

接下来增加 set 函数,来拦截对象的修改操作:

let product = { price: 10, quantity: 2 };
let proxiedProduct = new Proxy(product, {
	get(target, key, receiver){
  	console.log('正在读取的数据:',key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver){
  	console.log('正在修改的数据:', key, ',值为:', value);
  	return Reflect.set(target, key, value, receiver);
  }
})
proxiedProduct.price = 20;
console.log(proxiedProduct.price); 
// 正在修改的数据: price ,值为: 20
// 正在读取的数据: price
// 20

这样便完成 get 和 set 函数来拦截对象的读取和修改的操作。为了方便对比 Vue 3 源码,我们将上面代码抽象一层,使它看起来更像 Vue3 源码:

function reactive(target){
	const handler = {  // ① 封装统一处理函数对象
  	get(target, key, receiver){
      console.log('正在读取的数据:',key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver){
      console.log('正在修改的数据:', key, ',值为:', value);
      return Reflect.set(target, key, value, receiver);
    }
  }
  
  return new Proxy(target, handler); // ② 统一调用 Proxy API
}

let product = reactive({price: 10, quantity: 2}); // ③ 将对象转换为响应式对象
product.price = 20;
console.log(product.price); 
// 正在修改的数据: price ,值为: 20
// 正在读取的数据: price
// 20

这样输出结果仍然不变。

4. 修改 track 和 trigger 函数

通过上面代码,我们已经实现一个简单 reactive() 函数,用来将普通对象转换为响应式对象。但是还缺少自动执行 track() 函数和 trigger() 函数,接下来修改上面代码:

const targetMap = new WeakMap();
let total = 0;
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => { 
  let depsMap = targetMap.get(target);
  if(!depsMap){
  	targetMap.set(target, (depsMap = new Map()));
  }
	let dep = depsMap.get(key);
  if(!dep) {
		depsMap.set(key, (dep = new Set()));
  }
	dep.add(effect);
}

const trigger = (target, key) => {
  const depsMap = targetMap.get(target);
  if(!depsMap) return;
	let dep = depsMap.get(key);
  if(dep) {
		dep.forEach( effect => effect() );
  }
};

const reactive = (target) => {
	const handler = {
  	get(target, key, receiver){
      console.log('正在读取的数据:',key);
      const result = Reflect.get(target, key, receiver);
      track(target, key);  // 自动调用 track 方法收集依赖
      return result;
    },
    set(target, key, value, receiver){
      console.log('正在修改的数据:', key, ',值为:', value);
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if(oldValue != result){
         trigger(target, key);  // 自动调用 trigger 方法执行依赖
      }
      return result;
    }
  }
  
  return new Proxy(target, handler);
}

let product = reactive({price: 10, quantity: 2}); 
effect();
console.log(total); 
product.price = 20;
console.log(total); 
// 正在读取的数据: price
// 正在读取的数据: quantity
// 20
// 正在修改的数据: price ,值为: 20
// 正在读取的数据: price
// 正在读取的数据: quantity
// 40

image.png
(图片来源:Vue Mastery)

三、activeEffect 和 ref

在上一节代码中,还存在一个问题: track 函数中的依赖( effect 函数)是外部定义的,当依赖发生变化, track 函数收集依赖时都要手动修改其依赖的方法名。

比如现在的依赖为 foo 函数,就要修改 track 函数的逻辑,可能是这样:

const foo = () => { /**/ };
const track = (target, key) => {     // ②
  // ...
	dep.add(foo);
}

那么如何解决这个问题呢?

1. 引入 activeEffect 变量

接下来引入 activeEffect 变量,来保存当前运行的 effect 函数。

let activeEffect = null;
const effect = eff => {
	activeEffect = eff; // 1. 将 eff 函数赋值给 activeEffect
  activeEffect();     // 2. 执行 activeEffect
  activeEffect = null;// 3. 重置 activeEffect
}

然后在 track 函数中将 activeEffect 变量作为依赖:

const track = (target, key) => {
    if (activeEffect) {  // 1. 判断当前是否有 activeEffect
        let depsMap = targetMap.get(target);
        if (!depsMap) {
            targetMap.set(target, (depsMap = new Map()));
        }
        let dep = depsMap.get(key);
        if (!dep) {
            depsMap.set(key, (dep = new Set()));
        }
        dep.add(activeEffect);  // 2. 添加 activeEffect 依赖
    }
}

使用方式修改为:

effect(() => {
    total = product.price * product.quantity
});

这样就可以解决手动修改依赖的问题,这也是 Vue3 解决该问题的方法。完善一下测试代码后,如下:

const targetMap = new WeakMap();
let activeEffect = null; // 引入 activeEffect 变量

const effect = eff => {
	activeEffect = eff; // 1. 将副作用赋值给 activeEffect
  activeEffect();     // 2. 执行 activeEffect
  activeEffect = null;// 3. 重置 activeEffect
}

const track = (target, key) => {
    if (activeEffect) {  // 1. 判断当前是否有 activeEffect
        let depsMap = targetMap.get(target);
        if (!depsMap) {
            targetMap.set(target, (depsMap = new Map()));
        }
        let dep = depsMap.get(key);
        if (!dep) {
            depsMap.set(key, (dep = new Set()));
        }
        dep.add(activeEffect);  // 2. 添加 activeEffect 依赖
    }
}

const trigger = (target, key) => {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    let dep = depsMap.get(key);
    if (dep) {
        dep.forEach(effect => effect());
    }
};

const reactive = (target) => {
    const handler = {
        get(target, key, receiver) {
            const result = Reflect.get(target, key, receiver);
            track(target, key);
            return result;
        },
        set(target, key, value, receiver) {
            const oldValue = target[key];
            const result = Reflect.set(target, key, value, receiver);
            if (oldValue != result) {
                trigger(target, key);
            }
            return result;
        }
    }

    return new Proxy(target, handler);
}

let product = reactive({ price: 10, quantity: 2 });
let total = 0, salePrice = 0;
// 修改 effect 使用方式,将副作用作为参数传给 effect 方法
effect(() => {
    total = product.price * product.quantity
});
effect(() => {
    salePrice = product.price * 0.9
});
console.log(total, salePrice);  // 20 9
product.quantity = 5;
console.log(total, salePrice);  // 50 9
product.price = 20;
console.log(total, salePrice);  // 100 18

思考一下,如果把第一个 effect 函数中 product.price 换成 salePrice 会如何:

effect(() => {
    total = salePrice * product.quantity
});
effect(() => {
    salePrice = product.price * 0.9
});
console.log(total, salePrice);  // 0 9
product.quantity = 5;
console.log(total, salePrice);  // 45 9
product.price = 20;
console.log(total, salePrice);  // 45 18

得到的结果完全不同,因为 salePrice 并不是响应式变化,而是需要调用第二个 effect 函数才会变化,也就是 product.price 变量值发生变化。

代码地址:
https://github.com/Code-Pop/vue-3-reactivity/blob/master/05-activeEffect.js

2. 引入 ref 方法

熟悉 Vue3 Composition API 的朋友可能会想到 Ref,它接收一个值,并返回一个响应式可变的 Ref 对象,其值可以通过 value 属性获取。

ref:接受一个内部值并返回一个响应式且可变的 ref 对象。ref 对象具有指向内部值的单个 property .value。

官网的使用示例如下:

const count = ref(0)
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

我们有 2 种方法实现 ref 函数:

  1. 使用 rective 函数
const ref = intialValue => reactive({value: intialValue});

这样是可以的,虽然 Vue3 不是这么实现。

  1. 使用对象的属性访问器(计算属性)

属性方式去包括:gettersetter

const ref = raw => {
	const r = {
  	get value(){
    	track(r, 'value');
      return raw;
    },
    
    set value(newVal){
    	raw = newVal;
      trigger(r, 'value');
    }
  }
	return r;
}

使用方式如下:

let product = reactive({ price: 10, quantity: 2 });
let total = 0, salePrice = ref(0);
effect(() => {
    salePrice.value = product.price * 0.9
});
effect(() => {
    total = salePrice.value * product.quantity
});
console.log(total, salePrice.value); // 18 9
product.quantity = 5;
console.log(total, salePrice.value); // 45 9
product.price = 20;
console.log(total, salePrice.value); // 90 18

在 Vue3 中 ref 实现的核心也是如此。

代码地址:
https://github.com/Code-Pop/vue-3-reactivity/blob/master/06-ref.js

四、实现简易 Computed 方法

用过 Vue 的同学可能会好奇,上面的 salePricetotal 变量为什么不使用 computed 方法呢?

没错,这个可以的,接下来一起实现个简单的 computed 方法。

const computed = getter => {
    let result = ref();
    effect(() => result.value = getter());
    return result;
}

let product = reactive({ price: 10, quantity: 2 });
let salePrice = computed(() => {
    return product.price * 0.9;
})
let total = computed(() => {
    return salePrice.value * product.quantity;
})

console.log(total.value, salePrice.value);
product.quantity = 5;
console.log(total.value, salePrice.value);
product.price = 20;
console.log(total.value, salePrice.value);

这里我们将一个函数作为参数传入 computed 方法,computed 方法内通过 ref 方法构建一个 ref 对象,然后通过 effct 方法,将 getter 方法返回值作为 computed 方法的返回值。

这样我们实现了个简单的 computed 方法,执行效果和前面一样。

五、源码学习建议

1. 构建 reactivity.cjs.js

这一节介绍如何去从 Vue 3 仓库打包一个 Reactivity 包来学习和使用。

准备流程如下:

  1. Vue 3 仓库下载最新 Vue3 源码;
git clone https://github.com/vuejs/vue-next.git
  1. 安装依赖:
yarn install
  1. 构建 Reactivity 代码:
yarn build reactivity
  1. 复制 reactivity.cjs.js 到你的学习 demo 目录:

上一步构建完的内容,会保存在 packages/reactivity/dist目录下,我们只要在自己的学习 demo 中引入该目录的  reactivity.cjs.js 文件即可。

  1. 学习 demo 中引入:
const { reactive, computed, effect } = require("./reactivity.cjs.js");

2. Vue3 Reactivity 文件目录

在源码的 packages/reactivity/src目录下,有以下几个主要文件:

  1. effect.ts:用来定义 effect / track / trigger ;
  2. baseHandlers.ts:定义 Proxy 处理器( get 和 set);
  3. reactive.ts:定义 reactive 方法并创建 ES6 Proxy;
  4. ref.ts:定义 reactive 的 ref 使用的对象访问器;
  5. computed.ts:定义计算属性的方法;

image.png
(图片来源:Vue Mastery)

六、总结

本文带大家从头开始学习如何实现简单版 Vue 3 响应式,实现了 Vue3 Reactivity 中的核心方法( effect / track / trigger / computed /ref 等方法),帮助大家了解其核心,提高项目开发效率和代码调试能力

参考文章

往期推荐

  1. 探索 React 合成事件
  2. 探索 Vue.js 响应式原理
  3. 探索 Snabbdom 模块系统原理

我是王平安,如果我的文章对你有帮助,请点个 赞👍🏻 支持我一下

我的公众号:前端自习课,每日清晨,享受一篇前端优秀文章。欢迎大家加入我的前端群,一起分享和交流技术,vx: pingan8787

【原理】深入理解 Webpack Scope Hoisting

近期原创文章回顾😄

一、什么是 Scope Hoisting

Scope Hoisting 是 webpack3 的新功能,直译为 "作用域提升",它可以让 webpack 打包出来的代码文件更小运行更快

在 JavaScript 中,还有“变量提升”和“函数提升”,JavaScript 会将变量和函数的声明提升到当前作用域顶部,而“作用域提升”也类似,webpack 将引入到 JS 文件“提升到”它的引入者的顶部。

首先回顾下没有 Scope Hoisting 时用 webpack 打包下面两个文件:

// main.js
export default "hello leo~";

// index.js
import str from "./main.js";
console.log(str);

使用 webpack 打包后输出文件内容如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__main_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__main_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('hello leo~');
  })
]

再开启 Scope Hoisting 后,相同源码打包输出结果变为:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var main = ('hello leo~');
    console.log(main);
  })
]

对比两种打包方式输出的代码,我们可以看出,启用 Scope Hoisting 后,函数声明变成一个, main.js 中定义的内容被直接注入到 index.js 对应模块中,这样做的好处:

  • 代码体积更小,因为函数申明语句会产生大量代码,导致包体积增大(模块越多越明显);
  • 代码在运行时因为创建的函数作用域更少,内存开销也随之变小

二、webpack 模块机制

我们使用下面 webpack.config.js 配置,打包来看看 webpack 模块机制:

// webpack.config.js
const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
    },
    mode: 'none',
    optimization: {
        usedExports: true,
    },
};

打包后输出结果(精简后):


通过分析,我们可以得出以下结论:

  • webpack 打包输出打是一个 IIFE(匿名闭包);

  • modules  是一个数组,每一项是一个模块初始化函数;

  • 使用 __webpack_require() 来加载模块,返回 module.exports ;

  • 通过 __webpack_require__(__webpack_require__.s = 0); 启动程序。

三、Scope Hoisting 原理

Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能将打散的模块合并到一个函数中,前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并

由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。 原因和4-10 使用 TreeShaking 中介绍的类似。

四、Scope Hoisting 使用方式

1. 自动启用

在 webpack 的 mode 设置为 production 时,会默认自动启用 Scope Hooting。

// webpack.config.js

// ...
module.exports = {
  // ...
	mode: "production"
};

2. 手动启用

在 webpack 中已经内置 Scope Hoisting ,所以用起来很简单,只需要配置ModuleConcatenationPlugin 插件即可:

// webpack.config.js

// ...
const webpack = require('webpack');
module.exports = {
    // ...
    plugins: [
        new webpack.optimize.ModuleConcatenationPlugin()
    ]
};

考虑到 Scope Hoisting 以来 ES6 模块化语法,而现在很多 npm 包的第三方库还是使用 CommonJS 语法,为了充分发挥 Scope Hoisting 效果,我们可以增加以下 mainFields 配置:

// webpack.config.js

// ...
const webpack = require('webpack');
module.exports = {
    // ...
    resolve: {
        // 针对 npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
        mainFields: ['jsnext:main', 'browser', 'main']
    },
    plugins: [
        new webpack.optimize.ModuleConcatenationPlugin()
    ]
};

针对非 ES6 模块化语法的代码,webpack 会降级处理不使用 Scope Hoisting 优化,我们可以在 webpack 命令上增加 --display-optimization-bailout 参数,在输出的日志查看哪些代码做了降级处理:

// package.json
{
  // ...
  "scripts": {
    "build": "webpack --display-optimization-bailout" 
  }
}

我们写个简单示例代码:

// index.js
import str from "./main.js";
const { name } = require('./no-es6.js');

// main.js
export default "hello leo~";

// no-es6.js
module.exports = {
    name : "leo"
}

接着打包测试,可以看到控制台输出下面日志:

输出的日志中 ModuleConcatenation bailout 告诉我们哪些文件因为什么原因导致降级处理了。

五、总结

本文主要和大家一起回顾了 Scope Hoisting 基本概念,使用方式和使用后效果对比,希望大家不要只停留在会用 webpack,也要看看其中一些不常见的知识,比如本文介绍的 Scope Hoisting,它对我们项目优化非常有帮助,但平常又很少会去注意。

六、参考文章

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推荐 https://github.com/pingan8787/Leo_Reading/issues
ES小册 js.pingan8787.com
语雀知识库 Cute-FrontEnd

【总结】深入学习 tsconfig.json 配置

在 TypeScript 开发中,tsconfig.json 是个不可或缺的配置文件,它是我们在 TS 项目中最常见的配置文件,那么你真的了解这个文件吗?它里面都有哪些优秀配置?如何配置一个合理的 tsconfig.json 文件?本文将全面带大家一起详细了解 tsconfig.json 的各项配置。

本文将从以下几个方面全面介绍 tsconfig.json 文件:
了不起的 tsconfig.json 指南.png

水平有限,欢迎各位大佬指点~~

一、tsconfig.json 简介

1. 什么是 tsconfig.json

TypeScript 使用 tsconfig.json 文件作为其配置文件,当一个目录中存在 tsconfig.json 文件,则认为该目录为 TypeScript 项目的根目录。

通常 tsconfig.json 文件主要包含两部分内容:指定待编译文件定义编译选项

从《TypeScript编译器的配置文件的JSON模式》可知,目前 tsconfig.json 文件有以下几个顶层属性:

  • compileOnSave
  • compilerOptions
  • exclude
  • extends
  • files
  • include
  • references
  • typeAcquisition

文章后面会详细介绍一些常用属性配置。

2. 为什么使用 tsconfig.json

通常我们可以使用 tsc 命令来编译少量 TypeScript 文件:

/*
  参数介绍:
  --outFile // 编译后生成的文件名称
  --target  // 指定ECMAScript目标版本
  --module  // 指定生成哪个模块系统代码
  index.ts  // 源文件
*/
$ tsc --outFile leo.js --target es3 --module amd index.ts

但如果实际开发的项目,很少是只有单个文件,当我们需要编译整个项目时,就可以使用 tsconfig.json 文件,将需要使用到的配置都写进 tsconfig.json 文件,这样就不用每次编译都手动输入配置,另外也方便团队协作开发

二、使用 tsconfig.json

目前使用 tsconfig.json 有2种操作:

1. 初始化 tsconfig.json

在初始化操作,也有 2 种方式:

  1. 手动在项目根目录(或其他)创建 tsconfig.json 文件并填写配置;
  2. 通过 tsc --init 初始化 tsconfig.json 文件。

2. 指定需要编译的目录

在不指定输入文件的情况下执行 tsc 命令,默认从当前目录开始编译,编译所有 .ts 文件,并且从当前目录开始查找 tsconfig.json 文件,并逐级向上级目录搜索。

$ tsc

另外也可以为 tsc 命令指定参数 --project-p 指定需要编译的目录,该目录需要包含一个 tsconfig.json 文件,如:

/*
  文件目录:
  ├─src/
  │  ├─index.ts
  │  └─tsconfig.json
  ├─package.json
*/
$ tsc --project src

注意,tsc 的命令行选项具有优先级,会覆盖 tsconfig.json 中的同名选项

更多 tsc 编译选项,可查看《编译选项》章节。

三、使用示例

这个章节,我们将通过本地一个小项目 learnTsconfig 来学着实现一个简单配置。

当前开发环境:windows / node 10.15.1 / TypeScript3.9

1. 初始化 learnTsconfig 项目

执行下面命令:

$ mkdir learnTsconfig
$ cd .\learnTsconfig\
$ mkdir src
$ new-item index.ts

并且我们为 index.ts 文件写一些简单代码:

// 返回当前版本号
function getVersion(version:string = "1.0.0"): string{
    return version;
}

console.log(getVersion("1.0.1"))

我们将获得这么一个目录结构:

  └─src/
     └─index.ts

2. 初始化 tsconfig.json 文件

在 learnTsconfig 根目录执行:

$ tsc --init

3. 修改 tsconfig.json 文件

我们设置几个常见配置项:

{
  "compilerOptions": {
    "target": "ES5",             // 目标语言的版本
    "module": "commonjs",        // 指定生成代码的模板标准
    "noImplicitAny": true,       // 不允许隐式的 any 类型
    "removeComments": true,      // 删除注释 
    "preserveConstEnums": true,  // 保留 const 和 enum 声明
    "sourceMap": true            // 生成目标文件的sourceMap文件
  },
  "files": [   // 指定待编译文件
    "./src/index.ts"  
  ]
}

其中需要注意一点:
files 配置项值是一个数组,用来指定了待编译文件,即入口文件
当入口文件依赖其他文件时,不需要将被依赖文件也指定到 files 中,因为编译器会自动将所有的依赖文件归纳为编译对象,即 index.ts 依赖 user.ts 时,不需要在 files 中指定 user.ts , user.ts 会自动纳入待编译文件。

4. 执行编译

配置完成后,我们可以在命令行执行 tsc 命令,执行编译完成后,我们可以得到一个 index.js 文件和一个 index.js.map 文件,证明我们编译成功,其中 index.js 文件内容如下:

function getVersion(version) {
    if (version === void 0) { version = "1.0.0"; }
    return version;
}
console.log(getVersion("1.0.1"));
//# sourceMappingURL=index.js.map

可以看出,tsconfig.json 中的 removeComments 配置生效了,将我们添加的注释代码移除了。

到这一步,就完成了这个简单的示例,接下来会基于这个示例代码,讲解《七、常见配置示例》。

四、tsconfig.json 文件结构介绍

1. 按顶层属性分类

在 tsconfig.json 文件中按照顶层属性,分为以下几类:tsconfig.json 文件结构(顶层属性).png

了不起的 tsconfig.json 指南.png

2. 按功能分类

tsconfig.json 文件结构(功能).png

五、tsconfig.json 配置介绍

1. compileOnSave

compileOnSave 属性作用是设置保存文件的时候自动编译,但需要编译器支持

{
	// ...
  "compileOnSave": false,
}

2. compilerOptions

compilerOptions 属性作用是配置编译选项

compilerOptions 属性被忽略,则编译器会使用默认值,可以查看《官方完整的编译选项列表》

编译选项配置非常繁杂,有很多配置,这里只列出常用的配置。

{
  // ...
  "compilerOptions": {
    "incremental": true, // TS编译器在第一次编译之后会生成一个存储编译信息的文件,第二次编译会在第一次的基础上进行增量编译,可以提高编译的速度
    "tsBuildInfoFile": "./buildFile", // 增量编译文件的存储位置
    "diagnostics": true, // 打印诊断信息 
    "target": "ES5", // 目标语言的版本
    "module": "CommonJS", // 生成代码的模板标准
    "outFile": "./app.js", // 将多个相互依赖的文件生成一个文件,可以用在AMD模块中,即开启时应设置"module": "AMD",
    "lib": ["DOM", "ES2015", "ScriptHost", "ES2019.Array"], // TS需要引用的库,即声明文件,es5 默认引用dom、es5、scripthost,如需要使用es的高级版本特性,通常都需要配置,如es8的数组新特性需要引入"ES2019.Array",
    "allowJS": true, // 允许编译器编译JS,JSX文件
    "checkJs": true, // 允许在JS文件中报错,通常与allowJS一起使用
    "outDir": "./dist", // 指定输出目录
    "rootDir": "./", // 指定输出文件目录(用于输出),用于控制输出目录结构
    "declaration": true, // 生成声明文件,开启后会自动生成声明文件
    "declarationDir": "./file", // 指定生成声明文件存放目录
    "emitDeclarationOnly": true, // 只生成声明文件,而不会生成js文件
    "sourceMap": true, // 生成目标文件的sourceMap文件
    "inlineSourceMap": true, // 生成目标文件的inline SourceMap,inline SourceMap会包含在生成的js文件中
    "declarationMap": true, // 为声明文件生成sourceMap
    "typeRoots": [], // 声明文件目录,默认时node_modules/@types
    "types": [], // 加载的声明文件包
    "removeComments":true, // 删除注释 
    "noEmit": true, // 不输出文件,即编译后不会生成任何js文件
    "noEmitOnError": true, // 发送错误时不输出任何文件
    "noEmitHelpers": true, // 不生成helper函数,减小体积,需要额外安装,常配合importHelpers一起使用
    "importHelpers": true, // 通过tslib引入helper函数,文件必须是模块
    "downlevelIteration": true, // 降级遍历器实现,如果目标源是es3/5,那么遍历器会有降级的实现
    "strict": true, // 开启所有严格的类型检查
    "alwaysStrict": true, // 在代码中注入'use strict'
    "noImplicitAny": true, // 不允许隐式的any类型
    "strictNullChecks": true, // 不允许把null、undefined赋值给其他类型的变量
    "strictFunctionTypes": true, // 不允许函数参数双向协变
    "strictPropertyInitialization": true, // 类的实例属性必须初始化
    "strictBindCallApply": true, // 严格的bind/call/apply检查
    "noImplicitThis": true, // 不允许this有隐式的any类型
    "noUnusedLocals": true, // 检查只声明、未使用的局部变量(只提示不报错)
    "noUnusedParameters": true, // 检查未使用的函数参数(只提示不报错)
    "noFallthroughCasesInSwitch": true, // 防止switch语句贯穿(即如果没有break语句后面不会执行)
    "noImplicitReturns": true, //每个分支都会有返回值
    "esModuleInterop": true, // 允许export=导出,由import from 导入
    "allowUmdGlobalAccess": true, // 允许在模块中全局变量的方式访问umd模块
    "moduleResolution": "node", // 模块解析策略,ts默认用node的解析策略,即相对的方式导入
    "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
    "paths": { // 路径映射,相对于baseUrl
      // 如使用jq时不想使用默认版本,而需要手动指定版本,可进行如下配置
      "jquery": ["node_modules/jquery/dist/jquery.min.js"]
    },
    "rootDirs": ["src","out"], // 将多个目录放在一个虚拟目录下,用于运行时,即编译后引入文件的位置可能发生变化,这也设置可以虚拟src和out在同一个目录下,不用再去改变路径也不会报错
    "listEmittedFiles": true, // 打印输出文件
    "listFiles": true// 打印编译的文件(包括引用的声明文件)
  }
}

3. exclude

exclude 属性作用是指定编译器需要排除的文件或文件夹

默认排除 node_modules 文件夹下文件。

{
	// ...
  "exclude": [
    "src/lib" // 排除src目录下的lib文件夹下的文件不会编译
  ]
}

include 属性一样,支持 glob 通配符:

  • * 匹配0或多个字符(不包括目录分隔符)
  • ? 匹配一个任意字符(不包括目录分隔符)
  • **/ 递归匹配任意子目录

4. extends

extends 属性作用是引入其他配置文件,继承配置

默认包含当前目录和子目录下所有 TypeScript 文件。

{
	// ...
  // 把基础配置抽离成tsconfig.base.json文件,然后引入
	"extends": "./tsconfig.base.json"
}

5. files

files 属性作用是指定需要编译的单个文件列表

默认包含当前目录和子目录下所有 TypeScript 文件。

{
	// ...
  "files": [
    // 指定编译文件是src目录下的leo.ts文件
    "scr/leo.ts"
  ]
}

6. include

include 属性作用是指定编译需要编译的文件或目录

{
	// ...
  "include": [
    // "scr" // 会编译src目录下的所有文件,包括子目录
    // "scr/*" // 只会编译scr一级目录下的文件
    "scr/*/*" // 只会编译scr二级目录下的文件
  ]
}

7. references

references 属性作用是指定工程引用依赖。
在项目开发中,有时候我们为了方便将前端项目和后端node项目放在同一个目录下开发,两个项目依赖同一个配置文件和通用文件,但我们希望前后端项目进行灵活的分别打包,那么我们可以进行如下配置:

{
	// ...
  "references": [ // 指定依赖的工程
     {"path": "./common"}
  ]
}

8. typeAcquisition

typeAcquisition 属性作用是设置自动引入库类型定义文件(.d.ts)相关。
包含 3 个子属性:

  • enable  : 布尔类型,是否开启自动引入库类型定义文件(.d.ts),默认为 false;
  • include  : 数组类型,允许自动引入的库名,如:["jquery", "lodash"];
  • exculde  : 数组类型,排除的库名。
{
	// ...
  "typeAcquisition": {
    "enable": false,
    "exclude": ["jquery"],
    "include": ["jest"]
  }
}

六、常见配置示例

本部分内容中,我们找了几个实际开发中比较常见的配置,当然,还有很多配置需要自己摸索哟~~

1. 移除代码中注释

tsconfig.json:

{
  "compilerOptions": {
    "removeComments": true,
  }
}

编译前:

// 返回当前版本号
function getVersion(version:string = "1.0.0"): string{
    return version;
}
console.log(getVersion("1.0.1"))

编译结果:

function getVersion(version) {
    if (version === void 0) { version = "1.0.0"; }
    return version;
}
console.log(getVersion("1.0.1"));

2. 开启null、undefined检测

tsconfig.json:

{
    "compilerOptions": {
        "strictNullChecks": true
    },
}

修改 index.ts 文件内容:

const leo;
leo = new Pingan('leo','hello');

这时候编辑器也会提示错误信息,执行 tsc 后,控制台报错:

src/index.ts:9:11 - error TS2304: Cannot find name 'Pingan'.

9 leo = new Pingan('leo','hello');

Found 1 error.

3. 配置复用

通过 extends 属性实现配置复用,即一个配置文件可以继承另一个文件的配置属性。

比如,建立一个基础的配置文件 configs/base.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

tsconfig.json 就可以引用这个文件的配置了:

{
  "extends": "./configs/base",
  "files": [
    "main.ts",
    "supplemental.ts"
  ]
}

4. 生成枚举的映射代码

在默认情况下,使用 const 修饰符后,枚举不会生成映射代码。

如下,我们可以看出:使用 const 修饰符后,编译器不会生成任何 RequestMethod 枚举的任何映射代码,在其他地方使用时,内联每个成员的值,节省很大开销。

const enum RequestMethod {
  Get,
  Post,
  Put,
  Delete
}

let methods = [
  RequestMethod.Get,
  RequestMethod.Post
]

编译结果:

"use strict";
let methods = [
    0 /* Get */,
    1 /* Post */
];

当然,我们希望生成映射代码时,也可以设置 tsconfig.json 中的配置,设置 preserveConstEnums 编译器选项为 true

{
  "compilerOptions": {
    "target": "es5",
    "preserveConstEnums": true
  }
}

最后编译结果变成:

"use strict";
var RequestMethod;
(function (RequestMethod) {
    RequestMethod[RequestMethod["Get"] = 0] = "Get";
    RequestMethod[RequestMethod["Post"] = 1] = "Post";
    RequestMethod[RequestMethod["Put"] = 2] = "Put";
    RequestMethod[RequestMethod["Delete"] = 3] = "Delete";
})(RequestMethod || (RequestMethod = {}));
let methods = [
    0 /* Get */,
    1 /* Post */
];

5. 关闭 this 类型注解提示

通过下面代码编译后会报错:

const button = document.querySelector("button");
button?.addEventListener("click", handleClick);
function handleClick(this) {
 console.log("Clicked!");
 this.removeEventListener("click", handleClick);
}

报错内容:

src/index.ts:10:22 - error TS7006: Parameter 'this' implicitly has an 'any' type.
10 function handleClick(this) {
Found 1 error.

这是因为 this 隐式具有 any 类型,如果没有指定类型注解,编译器会提示“"this" 隐式具有类型 "any",因为它没有类型注释。”。

解决方法有2种:

  1. 指定 this 类型,如本代码中为 HTMLElement 类型:

HTMLElement 接口表示所有的 HTML 元素。一些HTML元素直接实现了 HTMLElement 接口,其它的间接实现HTMLElement接口。
关于 HTMLElement 可查看详细。

  1. 使用 --noImplicitThis 配置项: 

在 TS2.0 还增加一个新的编译选项: --noImplicitThis,表示当 this 表达式值为 any 类型时生成一个错误信息。我们设置为 true 后就能正常编译。

{
  "compilerOptions": {
    "noImplicitThis": true
  }
}

七、Webpack/React 中使用示例

1. 配置编译 ES6 代码,JSX 文件

创建测试项目 webpack-demo,结构如下:

webpack-demo/
  |- package.json
  |- tsconfig.json
  |- webpack.config.js
  |- /dist
    |- bundle.js
    |- index.html
  |- /src
    |- index.js
    |- index.ts
  |- /node_modules

安装 TypeScript 和 ts-loader:

$ npm install --save-dev typescript ts-loader

配置 tsconfig.json,支持 JSX,并将 TypeScript 编译为 ES5:

{
  "compilerOptions": {
    "outDir": "./dist/",
    "noImplicitAny": true,
+   "module": "es6",
+   "target": "es5",
+   "jsx": "react",
    "allowJs": true
  }
}

还需要配置 webpack.config.js,使其能够处理 TypeScript 代码,这里主要在 rules 中添加 ts-loader :

const path = require('path');

module.exports = {
  entry: './src/index.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    extensions: [ '.tsx', '.ts', '.js' ]
  },
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
};

2. 配置 source map

想要启用 source map,我们必须配置 TypeScript,以将内联的 source map 输出到编译后的 JavaScript 文件中。

只需要在 tsconfig.json 中配置 sourceMap 属性:

  {
    "compilerOptions": {
      "outDir": "./dist/",
+     "sourceMap": true,
      "noImplicitAny": true,
      "module": "commonjs",
      "target": "es5",
      "jsx": "react",
      "allowJs": true
    }
  }

然后配置 webpack.config.js 文件,让 webpack 提取 source map,并内联到最终的 bundle 中:

  const path = require('path');

  module.exports = {
    entry: './src/index.ts',
+   devtool: 'inline-source-map',
    module: {
      rules: [
        {
          test: /\.tsx?$/,
          use: 'ts-loader',
          exclude: /node_modules/
        }
      ]
    },
    resolve: {
      extensions: [ '.tsx', '.ts', '.js' ]
    },
    output: {
      filename: 'bundle.js',
      path: path.resolve(__dirname, 'dist')
    }
  };

八、总结

本文较全面介绍了 tsconfig.json 文件的知识,从“什么是 tsconfig.js 文件”开始,一步步带领大家全面认识 tsconfig.json 文件。
文中通过一个简单 learnTsconfig 项目,让大家知道项目中如何使用 tsconfig.json 文件。在后续文章中,我们将这么多的配置项进行分类学习。最后通过几个常见配置示例,解决我们开发中遇到的几个常见问题。

当然,本文篇幅有限,无法针对每个属性进行深入介绍,这就需要大家在实际开发中,多去尝试和使用啦~

九、学习和参考资料

1.《Intro to the TSConfig Reference》 
2.《tsconfig.json》 
3.《TypeScript编译器的配置文件的JSON模式》
4.《详解TypeScript项目中的tsconfig.json配置》 
5.《官方完整的编译选项列表》

关于我

Author 王平安
E-mail [email protected]
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推荐 https://github.com/pingan8787/Leo_Reading/issues
ES小册 js.pingan8787.com
语雀知识库 Cute-FrontEnd

bg

【原理】探索 Vue.js 响应式原理

封面图

提到“响应式”三个字,大家立刻想到啥?响应式布局?响应式编程?

响应式关键词.png
从字面意思可以看出,具有“响应式”特征的事物会根据条件变化,使得目标自动作出对应变化。比如在“响应式布局”中,页面根据不同设备尺寸自动显示不同样式。

Vue.js 中的响应式也是一样,当数据发生变化后,使用到该数据的视图也会相应进行自动更新。

接下来我根据个人理解,和大家一起探索下 Vue.js 中的响应式原理,如有错误,欢迎指点😺~~

一、Vue.js 响应式的使用

现在有个很简单的需求,点击页面中 “leo” 文本后,文本内容修改为“你好,前端自习课”。

我们可以直接操作 DOM,来完成这个需求:

<span id="name">leo</span>
const node = document.querySelector('#name')
node.innerText = '你好,前端自习课';

实现起来比较简单,当我们需要修改的数据有很多时(比如相同数据被多处引用),这样的操作将变得复杂。

既然说到 Vue.js,我们就来看看 Vue.js 怎么实现上面需求:

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      name: "leo",
    };
  },
  methods: {
    setName() {
      this.name = "你好,前端自习课";
    },
  },
};
</script>

观察上面代码,我们通过改变数据,来自动更新视图。当我们有多个地方引用这个 name 时,视图都会自动更新。

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
  </div>
</template>

当我们使用目前主流的前端框架 Vue.js 和 React 开发业务时,只需关注页面数据如何变化,因为数据变化后,视图也会自动更新,这让我们从繁杂的 DOM 操作中解脱出来,提高开发效率。

二、回顾观察者模式

前面反复提到“通过改变数据,来自动更新视图”,换个说法就是“数据改变后,使用该数据的地方被动发生响应,更新视图”。

是不是有种熟悉的感觉?数据无需关注自身被多少对象引用,只需在数据变化时,通知到引用的对象即可,引用的对象作出响应。恩,有种观察者模式的味道?

关于观察者模式,可阅读我之前写的《图解设计模式之观察者模式(TypeScript)》

1. 观察者模式流程

观察者模式表示一种“一对多”的关系,n 个观察者关注 1 个被观察者,被观察者可以主动通知所有观察者。接下图:

observer.png
在这张图中,粉丝想及时收到“前端自习课”最新文章,只需关注即可,“前端自习课”有新文章,会主动推送给每个粉丝。该过程中,“前端自习课”是被观察者,每位“粉丝”是观察者。

2. 观察者模式核心

观察者模式核心组成包括:n 个观察者和 1 个被观察者。这里实现一个简单观察者模式:

2.1 定义接口

// 观察目标接口
interface ISubject {
    addObserver: (observer: Observer) => void; // 添加观察者
    removeObserver: (observer: Observer) => void; // 移除观察者
    notify: () => void; // 通知观察者
}

// 观察者接口
interface IObserver {
    update: () => void;
}

2.2 实现被观察者类

// 实现被观察者类
class Subject implements ISubject {
    private observers: IObserver[] = [];

    public addObserver(observer: IObserver): void {
        this.observers.push(observer);
    }

    public removeObserver(observer: IObserver): void {
        const idx: number = this.observers.indexOf(observer);
        ~idx && this.observers.splice(idx, 1);
    }

    public notify(): void {
        this.observers.forEach(observer => {
            observer.update();
        });
    }
}

2.3 实现观察者类

// 实现观察者类
class Observer implements IObserver {
    constructor(private name: string) { }

    update(): void {
        console.log(`${this.name} has been notified.`);
    }
}

2.4 测试代码

function useObserver(){
    const subject: ISubject = new Subject();
    const Leo = new Observer("Leo");
    const Robin = new Observer("Robin");
    const Pual = new Observer("Pual");

    subject.addObserver(Leo);
    subject.addObserver(Robin);
    subject.addObserver(Pual);
    subject.notify();

    subject.removeObserver(Pual);
    subject.notify();
}

useObserver();
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 
// [LOG]: "Pual has been notified." 
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 

三、回顾 Object.defineProperty()

Vue.js 的数据响应式原理是基于 JS 标准内置对象方法 Object.defineProperty() 方法来实现,该方法不兼容 IE8 和 FF22 及以下版本浏览器,这也是为什么 Vue.js 只能在这些版本之上的浏览器中才能运行的原因。

理解 Object.defineProperty() 对我们理解 Vue.js 响应式原理非常重要

Vue.js 3 使用 proxy 方法实现响应式,两者类似,我们只需搞懂Object.defineProperty()proxy 也就差不多理解了。

1. 概念介绍

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
语法如下:

Object.defineProperty(obj, prop, descriptor)
  • 入参说明:

obj :要定义属性的源对象

prop :要定义或修改的属性名称Symbol

descriptor :要定义或修改的属性描述符,包括 configurableenumerablevaluewritablegetset,具体的可以去参阅文档

  • 出参说明:

修改后的源对象。

举个简单🌰例子:

const leo = {};
Object.defineProperty(leo, 'age', { 
    value: 18,
    writable: true
})
console.log(leo.age); // 18
leo.age = 22;
console.log(leo.age); // 22

2. 实现 getter/setter

我们知道 Object.defineProperty() 方法第三个参数是属性描述符(descriptor),支持设置 getset 描述符:

  • get 描述符:当访问该属性时,会调用此函数,默认值为 undefined ;
  • set 描述符:当修改该属性时,会调用此函数,默认值为 undefined

一旦对象拥有了 getter/setter 方法,我们可以简单将该对象称为响应式对象。

这两个操作符为我们提供拦截数据进行操作的可能性,修改前面示例,添加 getter/setter 方法:

let leo = {}, age = 18;
Object.defineProperty(leo, 'age', { 
    get(){
        // to do something
      	console.log('监听到请求数据');
        return age;
    },
    set(newAge){
        // to do something
      	console.log('监听到修改数据');
        age = newAge > age ? age : newAge
    }
})
leo.age = 20;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 18

leo.age = 10;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 10

访问 leo 对象的 age 属性,会通过 get 描述符处理,而修改 age 属性,则会通过 set 描述符处理。

四、实现简单的数据响应式

通过前面两个小节,我们复习了“观察者模式”和“Object.defineProperty()” 方法,这两个知识点在 Vue.js 响应式原理中非常重要。

接下来我们来实现一个很简单的数据响应式变化,需求如下:点击“更新数据”按钮,文本更新。

data-change.png

接下来我们将实现三个类:

  • Dep 被观察者类,用来生成被观察者;
  • Watcher 观察者类,用来生成观察者;
  • Observer 类,将普通数据转换为响应式数据,从而实现响应式对象

用一张图来描述三者之间关系,现在看不懂没关系,这小节看完可以再回顾这张图:
observer-watcher-dep.png

1. 实现精简观察者模式

这里参照前面复习“观察者模式”的示例,做下精简:

// 实现被观察者类
class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}
// 实现观察者类
class Watcher {
    constructor(cb) {
        this.cb = cb;
    }
    update(data) {
        this.cb(data);
    }
}

Vue.js 响应式原理中,观察者模式起到非常重要的作用。其中:

  • Dep 被观察者类,提供用来收集观察者( addSub )方法和通知观察者( notify )方法;
  • Watcher 观察者类,实例化时支持传入回调( cb )方法,并提供更新( update )方法;

2. 实现生成响应式的类

这一步需要实现 Observer 类,核心是通过 Object.defineProperty() 方法为对象的每个属性设置 getter/setter,目的是将普通数据转换为响应式数据,从而实现响应式对象

reactive-data.png

这里以最简单的单层对象为例(下一节会介绍深层对象),如:

let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

接下来实现 Observer 类:

// 实现响应式类(最简单单层的对象,暂不考虑深层对象)
class Observer {
    constructor (node, data) {
        this.defineReactive(node, data)
    }

    // 实现数据劫持(核心方法)
    // 遍历 data 中所有的数据,都添加上 getter 和 setter 方法
    defineReactive(vm, obj) {
        //每一个属性都重新定义get、set
        for(let key in obj){
            let value = obj[key] dep = new Dep();
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get() {
                    // 创建观察者
                    let watcher = new Watcher(v => vm.innerText = v);
                    dep.addSub(watcher);
                    return value;
                },
                set(newValue) {
                    value = newValue;
                    // 通知所有观察者
                    dep.notify(newValue);
                }
            })
        }
    }
}

上面代码的核心是 defineReactive 方法,它遍历原始对象中每个属性,为每个属性实例化一个被观察者(Dep),然后分别调用 Object.defineProperty() 方法,为每个属性添加 getter/setter。

  • 访问数据时,getter 执行依赖收集(即添加观察者),通过实例化 Watcher 创建一个观察者,并执行被观察者的 addSub() 方法添加一个观察者;
  • 修改数据时,setter 执行派发更新(即通知观察者),通过调用被观察者的 notify() 方法通知所有观察者,执行观察者 update() 方法。

3. 测试代码

为了方便观察数据变化,我们为“更新数据”按钮绑定点击事件来修改数据:

<div id="app"></div>
<button id="update">更新数据</button>

测试代码如下:

// 初始化测试数据
let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

const app = document.querySelector('#app');

// 步骤1:为测试数据转换为响应式对象
new Observer(app, initData);

// 步骤2:初始化页面文本内容
app.innerText = initData.text;

// 步骤3:绑定按钮事件,点击触发测试
document.querySelector('#update').addEventListener('click', function(){
    initData.text = `我们必须经常保持旧的记忆和新的希望。`;
    console.log(`当前时间:${new Date().toLocaleString()}`)
})

测试代码中,核心在于通过实例化 Observer,将测试数据转换为响应式数据,然后模拟数据变化,来观察视图变化。
每次点击“更新数据”按钮,在控制台中都能看到“数据发生变化!”的提示,说明我们已经能通过 setter 观察到数据的变化情况。

当然,你还可以在控制台手动修改 initData 对象中的 text 属性,来体验响应式变化~~

到这里,我们实现了非常简单的数据响应式变化,当然 Vue.js 肯定没有这么简单,这个先理解,下一节看 Vue.js 响应式原理,思路就会清晰很多。

这部分代码,我已经放到我的 Github,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/Basics-Reactive-Demo.js

可以再回顾下这张图,对整个过程会更清晰:

observer-watcher-dep.png

五、实现简单 Vue.js 响应式

本节代码:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/

这里大家可以再回顾下下面这张官网经典的图,思考下前面讲的示例。

(图片来自:https://cn.vuejs.org/v2/guide/reactivity.html

上一节实现了简单的数据响应式,接下来继续通过完善该示例,实现一个简单的 Vue.js 响应式,测试代码如下:

// index.js
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

是不是很有内味了,下面是我们最终实现后项目目录:

- mini-reactive
	/ index.html   // 入口 HTML 文件
  / index.js     // 入口 JS 文件
  / observer.js  // 实现响应式,将数据转换为响应式对象
  / watcher.js   // 实现观察者和被观察者(依赖收集者)
  / vue.js       // 实现 Vue 类作为主入口类
  / compile.js   // 实现编译模版功能

知道每一个文件功能以后,接下来将每一步串联起来。

1. 实现入口文件

我们首先实现入口文件,包括 index.html / index.js  2 个简单文件,用来方便接下来的测试。

1.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="./vue.js"></script>
    <script src="./observer.js"></script>
    <script src="./compile.js"></script>
    <script src="./watcher.js"></script>
</head>
<body>
    <div id="app">{{text}}</div>
    <button id="update">更新数据</button>
    <script src="./index.js"></script>
</body>
</html>

1.2 index.js

"use strict";
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

console.log(vm.$data.text)
vm.$data.text = '页面数据更新成功!'; // 模拟数据变化
/*
	也可以手动绑定“更新数据”按钮的事件,来手动更新数据
  document.getElementById('update').addEventListener('click', function(){
    vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';
  })
*/
console.log(vm.$data.text)

2. 实现核心入口 vue.js

vue.js 文件是我们实现的整个响应式的入口文件,暴露一个 Vue 类,并挂载全局。

class Vue {
    constructor (options = {}) {
        this.$el = options.el;
        this.$data = options.data();
        this.$methods = options.methods;

        // [核心流程]将普通 data 对象转换为响应式对象
        new Observer(this.$data);

        if (this.$el) {
            // [核心流程]将解析模板的内容
            new Compile(this.$el, this)
        }
    }
}
window.Vue = Vue;

Vue 类入参为一个配置项 option ,使用起来跟 Vue.js 一样,包括 $el 挂载点、 $data 数据对象和 $methods 方法列表(本文不详细介绍)。

通过实例化 Oberser 类,将普通 data 对象转换为响应式对象,然后判断是否传入 el 参数,存在时,则实例化 Compile 类,解析模版内容。

总结下 Vue 这个类工作流程 :
vue-class.png

3. 实现 observer.js

observer.js 文件实现了 Observer 类,用来将普通对象转换为响应式对象:

class Observer {
    constructor (data) {
        this.data = data;
        this.walk(data);
    }

    // [核心方法]将 data 对象转换为响应式对象,为每个 data 属性设置 getter 和 setter 方法
    walk (data) {
        if (typeof data !== 'object') return data;
        Object.keys(data).forEach( key => {
            this.defineReactive(data, key, data[key])
        })
    }

    // [核心方法]实现数据劫持
    defineReactive (obj, key, value) {
        this.walk(value);  // [核心过程]遍历 walk 方法,处理深层对象。
        const dep = new Dep();
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log('[getter]方法执行')
                Dep.target &&  dep.addSub(Dep.target);
                return value
            },
            set (newValue) {
                console.log('[setter]方法执行')
                if (value === newValue) return;
                // [核心过程]当设置的新值 newValue 为对象,则继续通过 walk 方法将其转换为响应式对象
                if (typeof newValue === 'object') this.walk(newValue);
                value = newValue;
                dep.notify(); // [核心过程]执行被观察者通知方法,通知所有观察者执行 update 更新
            }
        })
    }
}

相比较第四节实现的 Observer 类,这里做了调整:

  • 增加 walk 核心方法,用来遍历对象每个属性,分别调用数据劫持方法( defineReactive() );
  • defineReactive() 的 getter 中,判断 Dep.target 存在才添加观察者,下一节会详细介绍 Dep.target
  • defineReactive() 的 setter 中,判断当前新值( newValue )是否为对象,如果是,则直接调用 this.walk() 方法将当前对象再次转为响应式对象,处理深层对象

通过改善后的 Observer 类,我们就可以实现将单层或深层嵌套的普通对象转换为响应式对象

4. 实现 watcher.js

这里实现了 Dep 被观察者类(依赖收集者)和 Watcher 观察者类。

class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}

class Watcher {
    constructor (vm, key, cb) {
        this.vm = vm;   // vm:表示当前实例
        this.key = key; // key:表示当前操作的数据名称
        this.cb = cb;   // cb:表示数据发生改变之后的回调

        Dep.target = this; // 全局唯一
      
        // 此处通过 this.vm.$data[key] 读取属性值,触发 getter
        this.oldValue = this.vm.$data[key]; // 保存变化的数据作为旧值,后续作判断是否更新

        // 前面 getter 执行完后,执行下面清空
        Dep.target = null;
    }
    
    update () {
        console.log(`数据发生变化!`);
        let oldValue = this.oldValue;
        let newValue = this.vm.$data[this.key];
        if (oldValue != newValue) {  // 比较新旧值,发生变化才执行回调
            this.cb(newValue, oldValue);
        };
    }
}

相比较第四节实现的 Watcher  类,这里做了调整:

  • 在构造函数中,增加 Dep.target 值操作;
  • 在构造函数中,增加 oldValue 变量,保存变化的数据作为旧值,后续作为判断是否更新的依据;
  • update() 方法中,增加当前操作对象 key 对应值的新旧值比较,如果不同,才执行回调。

Dep.target当前全局唯一的订阅者,因为同一时间只允许一个订阅者被处理。target当前正在处理的目标订阅者,当前订阅者处理完就赋值为 null 。这里 Dep.target 会在 defineReactive() 的 getter 中使用到。

通过改善后的 Watcher 类,我们操作当前操作对象 key 对应值的时候,可以在数据有变化的情况才执行回调,减少资源浪费。

4. 实现 compile.js

compile.js 实现了 Vue.js 的模版编译,如将 HTML 中的 {{text}} 模版转换为具体变量的值。

compile.js 介绍内容较多,考虑到篇幅问题,并且本文核心介绍响应式原理,所以这里就暂时不介绍 compile.js 的实现,在学习的朋友可以到我 Github 上下载该文件直接下载使用即可,地址:
https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/compile.js

5. 测试代码

到这里,我们已经将第四节的 demo 改造成简易版 Vue.js 响应式,接下来打开 index.html 看看效果:

当 index.js 中执行到:

vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';

页面便发生更新,页面显示的文本内容从“你好,前端自习课”更新成“我们必须经常保持旧的记忆和新的希望。”。

到这里,我们的简易版 Vue.js 响应式原理实现好了,能跟着文章看到这里的朋友,给你点个大大的赞👍

六、总结

本文首先通过回顾观察者模式和 Object.defineProperty() 方法,介绍 Vue.js 响应式原理的核心知识点,然后带大家通过一个简单示例实现简单响应式,最后通过改造这个简单响应式的示例,实现一个简单 Vue.js 响应式原理的示例。

相信看完本文的朋友,对 Vue.js 的响应式原理的理解会更深刻,希望大家理清思路,再好好回味下~

参考资料

  1. 官方文档 - 深入响应式原理 
  2. 《浅谈Vue响应式原理》
  3. 《Vue的数据响应式原理》 

【总结】TypeScript 设计模式之观察者模式

前言

在之前两篇自测清单中,和大家分享了很多 JavaScript 基础知识,大家可以一起再回顾下~

本文是我在我们团队内部“现代 JavaScript 突击队”分享的一篇内容,第二期学习内容为“设计模式”系列,我会将我负责分享的知识整理成文章输出,希望能够和大家一起温故知新!

现代 JavaScript 突击队”学习总结:

  1. 《初中级前端 JavaScript 自测清单 - 1》
  2. 《初中级前端 JavaScript 自测清单 - 2》

一、模式介绍

1. 背景介绍

在软件系统中经常碰到这类需求:当一个对象的状态发生改变,某些与它相关的对象也要随之做出相应的变化。这是建立一种对象与对象之间的依赖关系,一个对象发生改变时将自动通知其他对象,其他对象将相应做出反应

我们将发生改变的对象称为观察目标,将被通知的对象称为观察者一个观察目标可以对应多个观察者,而且这些观察者之间没有相互联系,之后可以根据需要增加和删除观察者,使得系统更易于扩展,这就是观察者模式的产生背景。

2. 概念介绍

观察者模式(Observer Pattern):定义对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。观察者模式是一种对象行为型模式。

3. 生活场景

在所有浏览器事件(鼠标悬停,按键等事件)都是观察者模式的例子。

另外还有:

如我们订阅微信公众号“前端自习课”(观察目标),当“前端自习课”群发图文消息后,所有公众号粉丝(观察者)都会接收到这篇文章(事件),这篇文章的内容是发布者自定义的(自定义事件),粉丝阅读后作出特定操作(如:点赞,收藏,关注等)。

观察者模式.png

二、模式特点

1. 模式组成

在观察者模式中,通常包含以下角色:

  • 目标:Subject
  • 观察目标:ConcreteSubject
  • 观察者:Observer
  • 具体观察者:ConcreteObserver

2. UML 类图

UML 类图

图片来源:《TypeScript 设计模式之观察者模式》 

3. 优点

  • 观察者模式可以实现表示层和数据逻辑层的分离,并降低观察目标和观察者之间耦合度
  • 观察者模式支持简单广播通信自动通知所有已经订阅过的对象;
  • 观察者模式符合“开闭原则”的要求
  • 观察目标和观察者之间的抽象耦合关系能够单独扩展以及重用

4. 缺点

  • 当一个观察目标有多个直接或间接的观察者时,通知所有观察者的过程将会花费很多时间;
  • 当观察目标和观察者之间存在循环依赖时,观察目标会触发它们之间进行循环调用,可能导致系统崩溃
  • 观察者模式缺少相应机制,让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。

三、使用场景

在以下情况下可以使用观察者模式:

  • 在一个抽象模型中,一个对象的行为依赖于另一个对象的状态。即当目标对象的状态发生改变时,会直接影响到观察者的行为;
  • 一个对象需要通知其他对象发生反应,但不知道这些对象是谁。
  • 需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。

四、实战示例

1. 简单示例

  1. 定义观察目标接口(Subject)和观察者接口(Observer)
// ObserverPattern.ts

// 观察目标接口
interface Subject {
  addObserver: (observer: Observer) => void;
  deleteObserver: (observer: Observer) => void;
  notifyObservers: () => void;
}

// 观察者接口
interface Observer {
  notify: () => void;
}
  1. 定义具体观察目标类(ConcreteSubject)
// ObserverPattern.ts

// 具体观察目标类
class ConcreteSubject implements Subject{ 
  private observers: Observer[] = [];
	
  // 添加观察者
  public addObserver(observer: Observer): void {
    console.log(observer, " is pushed~~");
    this.observers.push(observer);
  }

  // 移除观察者
  public deleteObserver(observer: Observer): void {
    console.log(observer, " have deleted~~");
    const idx: number = this.observers.indexOf(observer);
    ~idx && this.observers.splice(idx, 1);
  }

  // 通知观察者
  public notifyObservers(): void {
    console.log("notify all the observers ", this.observers);
    this.observers.forEach(observer => { 
      // 调用 notify 方法时可以携带指定参数
      observer.notify();
    });
  }
}
  1. 定义具体观察者类(ConcreteObserver)
// ObserverPattern.ts

// 具体观
class ConcreteObserver implements Observer{
  constructor(private name: string) {}

  notify(): void {
    // 可以处理其他逻辑
    console.log(`${this.name} has been notified.`);
  }
}
  1. 测试代码
// ObserverPattern.ts

function useObserver(): void {
  const subject: Subject = new ConcreteSubject();
  const Leo   = new ConcreteObserver("Leo");
  const Robin = new ConcreteObserver("Robin");
  const Pual  = new ConcreteObserver("Pual");
  const Lisa  = new ConcreteObserver("Lisa");

  subject.addObserver(Leo);
  subject.addObserver(Robin);
  subject.addObserver(Pual);
  subject.addObserver(Lisa);
  subject.notifyObservers();
  
  subject.deleteObserver(Pual);
  subject.deleteObserver(Lisa);
  subject.notifyObservers();
}

useObserver();

完整演示代码如下:

// ObserverPattern.ts

interface Subject {
  addObserver: (observer: Observer) => void;
  deleteObserver: (observer: Observer) => void;
  notifyObservers: () => void;
}

interface Observer {
  notify: () => void;
}

class ConcreteSubject implements Subject{ 
  private observers: Observer[] = [];

  public addObserver(observer: Observer): void {
    console.log(observer, " is pushed~~");
    this.observers.push(observer);
  }

  public deleteObserver(observer: Observer): void {
    console.log(observer, " have deleted~~");
    const idx: number = this.observers.indexOf(observer);
    ~idx && this.observers.splice(idx, 1);
  }

  public notifyObservers(): void {
    console.log("notify all the observers ", this.observers);
    this.observers.forEach(observer => { 
      // 调用 notify 方法时可以携带指定参数
      observer.notify();
    });
  }
}

class ConcreteObserver implements Observer{
  constructor(private name: string) {}

  notify(): void {
    // 可以处理其他逻辑
    console.log(`${this.name} has been notified.`);
  }
}

function useObserver(): void {
  const subject: Subject = new ConcreteSubject();
  const Leo   = new ConcreteObserver("Leo");
  const Robin = new ConcreteObserver("Robin");
  const Pual  = new ConcreteObserver("Pual");
  const Lisa  = new ConcreteObserver("Lisa");

  subject.addObserver(Leo);
  subject.addObserver(Robin);
  subject.addObserver(Pual);
  subject.addObserver(Lisa);
  subject.notifyObservers();
  
  subject.deleteObserver(Pual);
  subject.deleteObserver(Lisa);
  subject.notifyObservers();
}

useObserver();

2. Vue.js 数据双向绑定实现原理

在 Vue.js 中,当我们修改数据状时,视图随之更新,这就是 Vue.js 的双向数据绑定(也称响应式原理),这是 Vue.js 中最独特的特性之一。
如果你对 Vue.js 的双向数据绑定还不清楚,建议先阅读官方文档《深入响应式原理》章节。

2.1 原理介绍

在官网中提供这么一张流程图,介绍了 Vue.js 响应式系统的整个流程:

原理介绍
图片来自:Vue.js 官网《深入响应式原理

在 Vue.js 中,每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”(“Touch” 过程)过的数据 property 记录为依赖(Collect as Dependency 过程)。之后当依赖项的 setter 触发时,会通知 watcher(Notify 过程),从而使它关联的组件重新渲染(Trigger re-render 过程)——这是一个典型的观察者模式。

这道面试题考察面试者对 Vue.js 底层原理的理解、对观察者模式的实现能力以及一系列重要的JS知识点,具有较强的综合性和代表性。

2.2 组成部分

在 Vue.js 数据双向绑定的实现逻辑中,包含三个关键角色:

  • observer(监听器):这里的 observer 不仅是订阅者(需要监听数据变化),同时还是发布者(对监听的数据进行转发)。
  • watcher(订阅者):watcher对象是**真正的订阅者, **observer 把数据转发给 watcher 对象。watcher 接收到新的数据后,执行视图更新。
  • compile(编译器):MVVM 框架特有的角色,负责对每个节点元素指令进行扫描和解析,处理指令的数据初始化、订阅者的创建等操作。

这三者的配合过程如图所示:
组成部分
图片来自:掘金小册《JavaScript 设计模式核⼼原理与应⽤实践》

2.3 实现核心代码 observer

首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的 gettersetter 函数。这样但凡这个对象的某个属性发生了改变,就会触发 setter 函数,进而通知到订阅者。这个 setter 函数,就是我们的监听器:

// observe方法遍历并包装对象属性
function observe(target) {
    // 若target是一个对象,则遍历它
    if(target && typeof target === 'object') {
        Object.keys(target).forEach((key)=> {
            // defineReactive方法会给目标属性装上“监听器”
            defineReactive(target, key, target[key])
        })
    }
}
// 定义defineReactive方法
function defineReactive(target, key, val) {
    // 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历
    observe(val)
    // 为当前属性安装监听器
    Object.defineProperty(target, key, {
         // 可枚举
        enumerable: true,
        // 不可配置
        configurable: false, 
        get: function () {
            return val;
        },
        // 监听器函数
        set: function (value) {
            console.log(`${target}属性的${key}属性从${val}值变成了了${value}`)
            val = value
        }
    });
}

下面实现订阅者 Dep

// 定义订阅者类Dep
class Dep {
    constructor() {
        // 初始化订阅队列
        this.subs = []
    }
    
    // 增加订阅者
    addSub(sub) {
        this.subs.push(sub)
    }
    
    // 通知订阅者(是不是所有的代码都似曾相识?)
    notify() {
        this.subs.forEach((sub)=>{
            sub.update()
        })
    }
}

现在我们可以改写 defineReactive 中的 setter 方法,在监听器里去通知订阅者了:

function defineReactive(target, key, val) {
    const dep = new Dep()
    // 监听当前属性
    observe(val)
    Object.defineProperty(target, key, {
        set: (value) => {
            // 通知所有订阅者
            dep.notify()
        }
    })
}

五、总结

观察者模式又称发布-订阅模式、模型-视图模式、源-监听器模式或从属者模式。是一种对象行为型模式。其定义了一种对象间的一对多依赖关系,当观察目标发生状态变化,会通知所有观察者对象,使它们自动更新。

在实际业务中,如果一个对象的行为依赖于另一个对象的状态。或者说当目标对象的状态发生改变时,会直接影响到观察者的行为,尽量考虑到使用观察者模式来实现。

六、拓展

观察者模式和发布-订阅模式两者很像,但其实区别比较大。例如:

  • 耦合度差异:观察者模式的耦合度就比发布-订阅模式要高;
  • 关注点不同:观察者模式需要知道彼此的存在,而发布-订阅模式则是通过调度中心来联系发布/订阅者。

下一篇文章见。

参考文章

1.《3. 观察者模式》
2.《TypeScript 设计模式之观察者模式》 
3.《JavaScript 设计模式核⼼原理与应⽤实践》

《Leo-JavaScript》修改记录备忘录

提交格式说明:

  • 日期:

[ 2019.02.28 ]

  • 描述:

整理需要修改的描述。

注意:

  • 当修改完成后,需要对本条记录做状态修改,添加一个如下👍的表情,表示本条备忘修改完成。

【源码】200 行 TypeScript 代码实现一个高效缓存库

这两天用到 cacheables 缓存库,觉得挺不错的,和大家分享一下我看完源码的总结。

一、介绍

「cacheables」正如它名字一样,是用来做内存缓存使用,其代码仅仅 200 行左右(不含注释),官方的介绍如下:
cacheable介绍

一个简单的内存缓存,支持不同的缓存策略,使用 TypeScript 编写优雅的语法。

它的特点:

  • 优雅的语法,包装现有 API 调用,节省 API 调用;
  • 完全输入的结果。不需要类型转换。
  • 支持不同的缓存策略。
  • 集成日志:检查 API 调用的时间。
  • 使用辅助函数来构建缓存 key。
  • 适用于浏览器和 Node.js。
  • 没有依赖。
  • 进行大范围测试。
  • 体积小,gzip 之后 1.43kb。

当我们业务中需要对请求等异步任务做缓存,避免重复请求时,完全可以使用上「cacheables」。

二、上手体验

上手 cacheables很简单,看看下面使用对比:

// 没有使用缓存
fetch("https://some-url.com/api");

// 有使用缓存
cache.cacheable(() => fetch("https://some-url.com/api"), "key");

接下来看下官网提供的缓存请求的使用示例:

1. 安装依赖

npm install cacheables
// 或者
pnpm add cacheables

2. 使用示例

import { Cacheables } from "cacheables";
const apiUrl = "http://localhost:3000/";

// 创建一个新的缓存实例  ①
const cache = new Cacheables({
  logTiming: true,
  log: true,
});

// 模拟异步任务
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// 包装一个现有 API 调用 fetch(apiUrl),并分配一个 key 为 weather
// 下面例子使用 'max-age' 缓存策略,它会在一段时间后缓存失效
// 该方法返回一个完整 Promise,就像' fetch(apiUrl) '一样,可以缓存结果。
const getWeatherData = () =>
  // ②
  cache.cacheable(() => fetch(apiUrl), "weather", {
    cachePolicy: "max-age",
    maxAge: 5000,
  });

const start = async () => {
  // 获取新数据,并添加到缓存中
  const weatherData = await getWeatherData();

  // 3秒之后再执行
  await wait(3000);

  // 缓存新数据,maxAge设置5秒,此时还未过期
  const cachedWeatherData = await getWeatherData();

  // 3秒之后再执行
  await wait(3000);

  // 缓存超过5秒,此时已过期,此时请求的数据将会再缓存起来
  const freshWeatherData = await getWeatherData();
};

start();

上面示例代码我们就实现一个请求缓存的业务,在 maxAge为 5 秒内的重复请求,不会重新发送请求,而是从缓存读取其结果进行返回。

3. API 介绍

官方文档中介绍了很多 API,具体可以从文档中获取,比较常用的如 cache.cacheable(),用来包装一个方法进行缓存。
所有 API 如下:

  • new Cacheables(options?): Cacheables
  • cache.cacheable(resource, key, options?): Promise<T>
  • cache.delete(key: string): void
  • cache.clear(): void
  • cache.keys(): string[]
  • cache.isCached(key: string): boolean
  • Cacheables.key(...args: (string | number)[]): string

可以通过下图加深理解:
简单原理图

三、源码分析

克隆 cacheables 项目下来后,可以看到主要逻辑都在 index.ts中,去掉换行和注释,代码量 200 行左右,阅读起来比较简单。
接下来我们按照官方提供的示例,作为主线来阅读源码。

1. 创建缓存实例

示例中第 ① 步中,先通过 new Cacheables()创建一个缓存实例,在源码中Cacheables类的定义如下,这边先删掉多余代码,看下类提供的方法和作用:

export class Cacheables {
  constructor(options?: CacheOptions) {
    this.enabled = options?.enabled ?? true;
    this.log = options?.log ?? false;
    this.logTiming = options?.logTiming ?? false;
  }
  // 使用提供的参数创建一个 key
  static key(): string {}

  // 删除一笔缓存
  delete(): void {}

  // 清除所有缓存
  clear(): void {}

  // 返回指定 key 的缓存对象是否存在,并且有效(即是否超时)
  isCached(key: string): boolean {}

  // 返回所有的缓存 key
  keys(): string[] {}

  // 用来包装方法调用,做缓存
  async cacheable<T>(): Promise<T> {}
}

这样就很直观清楚 cacheables 实例的作用和支持的方法,其 UML 类图如下:

UML1

在第 ① 步实例化时,Cacheables 内部构造函数会将入参保存起来,接口定义如下:

const cache = new Cacheables({
  logTiming: true,
  log: true,
});

export type CacheOptions = {
  // 缓存开关
  enabled?: boolean;
  // 启用/禁用缓存命中日志
  log?: boolean;
  // 启用/禁用计时
  logTiming?: boolean;
};

根据参数可以看出,此时我们 Cacheables 实例支持缓存日志和计时功能。

2. 包装缓存方法

第 ② 步中,我们将请求方法包装在 cache.cacheable方法中,实现使用 max-age作为缓存策略,并且有效期 5000 毫秒的缓存:

const getWeatherData = () =>
  cache.cacheable(() => fetch(apiUrl), "weather", {
    cachePolicy: "max-age",
    maxAge: 5000,
  });

其中,cacheable 方法是 Cacheables类上的成员方法,定义如下(移除日志相关代码):

// 执行缓存设置
async cacheable<T>(
  resource: () => Promise<T>,  // 一个返回Promise的函数
  key: string,  // 缓存的 key
  options?: CacheableOptions, // 缓存策略
): Promise<T> {
  const shouldCache = this.enabled
  // 没有启用缓存,则直接调用传入的函数,并返回调用结果
  if (!shouldCache) {
    return resource()
  }
	// ... 省略日志代码
  const result = await this.#cacheable(resource, key, options) // 核心
	// ... 省略日志代码
  return result
}

其中cacheable 方法接收三个参数:

  • resource:需要包装的函数,是一个返回 Promise 的函数,如 () => fetch()
  • key:用来做缓存的 key
  • options:缓存策略的配置选项;

返回 this.#cacheable私有方法执行的结果,this.#cacheable私有方法实现如下:

// 处理缓存,如保存缓存对象等
async #cacheable<T>(
  resource: () => Promise<T>,
  key: string,
  options?: CacheableOptions,
): Promise<T> {
  // 先通过 key 获取缓存对象
  let cacheable = this.#cacheables[key] as Cacheable<T> | undefined
	// 如果不存在该 key 下的缓存对象,则通过 Cacheable 实例化一个新的缓存对象
  // 并保存在该 key 下
  if (!cacheable) {
    cacheable = new Cacheable()
    this.#cacheables[key] = cacheable
  }
	// 调用对应缓存策略
  return await cacheable.touch(resource, options)
}

this.#cacheable私有方法接收的参数与 cacheable方法一样,返回的是 cacheable.touch方法调用的结果。
如果 key 的缓存对象不存在,则通过 Cacheable类创建一个,其 UML 类图如下:
UML2

3. 处理缓存策略

上一步中,会通过调用 cacheable.touch方法,来执行对应缓存策略,该方法定义如下:

// 执行缓存策略的方法
async touch(
  resource: () => Promise<T>,
  options?: CacheableOptions,
): Promise<T> {
  if (!this.#initialized) {
    return this.#handlePreInit(resource, options)
  }
  if (!options) {
    return this.#handleCacheOnly()
  }
	// 通过实例化 Cacheables 时候配置的 options 的 cachePolicy 选择对应策略进行处理
  switch (options.cachePolicy) {
    case 'cache-only':
      return this.#handleCacheOnly()
    case 'network-only':
      return this.#handleNetworkOnly(resource)
    case 'stale-while-revalidate':
      return this.#handleSwr(resource)
    case 'max-age': // 本案例使用的类型
      return this.#handleMaxAge(resource, options.maxAge)
    case 'network-only-non-concurrent':
      return this.#handleNetworkOnlyNonConcurrent(resource)
  }
}

touch方法接收两个参数,来自 #cacheable私有方法参数的 resourceoptions
本案例使用的是 max-age缓存策略,所以我们看看对应的 #handleMaxAge私有方法定义(其他的类似):

// maxAge 缓存策略的处理方法
#handleMaxAge(resource: () => Promise<T>, maxAge: number) {
	// #lastFetch 最后发送时间,在 fetch 时会记录当前时间
	// 如果当前时间大于 #lastFetch + maxAge 时,会非并发调用传入的方法
  if (!this.#lastFetch || Date.now() > this.#lastFetch + maxAge) {
    return this.#fetchNonConcurrent(resource)
  }
  return this.#value // 如果是缓存期间,则直接返回前面缓存的结果
}

当我们第二次执行 getWeatherData() 已经是 6 秒后,已经超过 maxAge设置的 5 秒,所有之后就会缓存失效,重新发请求。

再看下 #fetchNonConcurrent私有方法定义,该方法用来发送非并发的请求:

// 发送非并发请求
async #fetchNonConcurrent(resource: () => Promise<T>): Promise<T> {
	// 非并发情况,如果当前请求还在发送中,则直接执行当前执行中的方法,并返回结果
  if (this.#isFetching(this.#promise)) {
    await this.#promise
    return this.#value
  }
  // 否则直接执行传入的方法
  return this.#fetch(resource)
}

#fetchNonConcurrent私有方法只接收参数 resource,即需要包装的函数。

这边先判断当前是否是【发送中】状态,如果则直接调用 this.#promise,并返回缓存的值,结束调用。否则将 resource 传入 #fetch执行。

#fetch私有方法定义如下:

// 执行请求发送
async #fetch(resource: () => Promise<T>): Promise<T> {
  this.#lastFetch = Date.now()
  this.#promise = resource() // 定义守卫变量,表示当前有任务在执行
  this.#value = await this.#promise
  if (!this.#initialized) this.#initialized = true
  this.#promise = undefined  // 执行完成,清空守卫变量
  return this.#value
}

#fetch 私有方法接收前面的需要包装的函数,并通过对守卫变量赋值,控制任务的执行,在刚开始执行时进行赋值,任务执行完成以后,清空守卫变量。

这也是我们实际业务开发经常用到的方法,比如发请求前,通过一个变量赋值,表示当前有任务执行,不能在发其他请求,在请求结束后,将该变量清空,继续执行其他任务。

完成任务。「cacheables」执行过程大致是这样,接下来我们总结一个通用的缓存方案,便于理解和拓展。

四、通用缓存库设计方案

在 Cacheables 中支持五种缓存策略,上面只介绍其中的 max-age

缓存策略

这里总结一套通用缓存库设计方案,大致如下图:

通用方案

该缓存库支持实例化是传入 options参数,将用户传入的 options.key作为 key,调用CachePolicyHandler对象中获取用户指定的缓存策略(Cache Policy)。
然后将用户传入的 options.resource作为实际要执行的方法,通过 CachePlicyHandler()方法传入并执行。

上图中,我们需要定义各种缓存库操作方法(如读取、设置缓存的方法)和各种缓存策略的处理方法。

当然也可以集成如 Logger等辅助工具,方便用户使用和开发。本文就不在赘述,核心还是介绍这个方案。

五、总结

本文与大家分享 cacheables 缓存库源码核心逻辑,其源码逻辑并不复杂,主要便是支持各种缓存策略和对应的处理逻辑。文章最后和大家归纳一种通用缓存库设计方案,大家有兴趣可以自己实战试试,好记性不如烂笔头。
思路最重要,这种思路可以运用在很多场景,大家可以在实际业务中多多练习和总结。​

六、还有几点思考

1. 思考读源码的方法

大家都在读源码,讨论源码,那如何读源码?
个人建议:

  1. 先确定自己要学源码的部分(如 Vue2 响应式原理、Vue3 Ref 等);
  2. 根据要学的部分,写个简单 demo;
  3. 通过 demo 断点进行大致了解;
  4. 翻阅源码,详细阅读,因为源码中往往会有注释和示例等。

如果你只是单纯想开始学某个库,可以先阅读 README.md,重点开介绍、特点、使用方法、示例等。抓住其特点、示例进行针对性的源码阅读。
相信这样阅读起来,思路会更清晰。

2. 思考面向接口编程

这个库使用了 TypeScript,通过每个接口定义,我们能很清晰的知道每个类、方法、属性作用。这也是我们需要学习的。
在我们接到需求任务时,可以这样做,你的效率往往会提高很多:

  1. 功能分析:对整个需求进行分析,了解需要实现的功能和细节,通过 xmind 等工具进行梳理,避免做着做着,经常返工,并且代码结构混乱。
  2. 功能设计:梳理完需求后,可以对每个部分进行设计,如抽取通用方法等,
  3. 功能实现:前两步都做好,相信功能实现已经不是什么难度了~

3. 思考这个库的优化点

这个库代码主要集中在 index.ts中,阅读起来还好,当代码量增多后,恐怕阅读体验比较不好。
所以我的建议是:

  1. 对代码进行拆分,将一些独立的逻辑拆到单独文件维护,比如每个缓存策略的逻辑,可以单独一个文件,通过统一开发方式开发(如 Plugin),再统一入口文件导入和导出。
  2. 可以将 Logger这类内部工具方法改造成支持用户自定义,比如可以使用其他日志工具方法,不一定使用内置 Logger,更加解耦。可以参考插件化架构设计,这样这个库会更加灵活可拓展。

【实战】从 0 到 1 上手 Web Components 业务组件库开发

组件化是前端发展的一个重要方向,它一方面提高开发效率,另一方面降低维护成本。主流的 Vue.js、React 及其延伸的 Ant Design、uniapp、Taro 等都是组件框架。

Web Components 是一组 Web 原生 API 的总称,允许我们创建可重用的自定义组件,并在我们 Web 应用中像使用原生 HTML 标签一样使用。目前已经很多前端框架/库支持 Web Components

本文将带大家回顾 Web Components 核心 API,并从 0 到 1 实现一个基于 Web Components API 开发的业务组件库。

最终效果:https://blog.pingan8787.com/exe-components/demo.html
仓库地址:https://github.com/pingan8787/Learn-Web-Components

一、回顾 Web Components

在前端发展历史中,从刚开始重复业务到处复制相同代码,到 Web Components 的出现,我们使用原生 HTML 标签的自定义组件,复用组件代码,提高开发效率。通过 Web Components 创建的组件,几乎可以使用在任何前端框架中。

1. 核心 API 回顾

Web Components 由 3 个核心 API 组成:

  • Custom elements(自定义元素):用来让我们定义自定义元素及其行为,对外提供组件的标签;
  • Shadow DOM(影子 DOM):用来封装组件内部的结构,避免与外部冲突;
  • HTML templates(HTML 模版):包括 <template><slot> 元素,让我们可以定义各种组件的 HTML 模版,然后被复用到其他地方,使用过 Vue/React 等框架的同学应该会很熟悉。

另外,还有 HTML imports,但目前已废弃,所以不具体介绍,其作用是用来控制组件的依赖加载。

image

2. 入门示例

接下来通过下面简单示例快速了解一下如何创建一个简单 Web Components 组件

  • 使用组件
<!DOCTYPE html>
<html lang="en">
<head>
    <script src="./index.js" defer></script>
</head>
<body>
    <h1>custom-element-start</h1>
    <custom-element-start></custom-element-start>
</body>
</html>
  • 定义组件
/**
 * 使用 CustomElementRegistry.define() 方法用来注册一个 custom element
 * 参数如下:
 * - 元素名称,符合 DOMString 规范,名称不能是单个单词,且必须用短横线隔开
 * - 元素行为,必须是一个类
 * - 继承元素,可选配置,一个包含 extends 属性的配置对象,指定创建的元素继承自哪个内置元素,可以继承任何内置元素。
 */

class CustomElementStart extends HTMLElement {
    constructor(){
        super();
        this.render();
    }
    render(){
        const shadow = this.attachShadow({mode: 'open'});
        const text = document.createElement("span");
        text.textContent = 'Hi Custom Element!';
        text.style = 'color: red';
        shadow.append(text);
    }
}

customElements.define('custom-element-start', CustomElementStart)

上面代码主要做 3 件事:

  1. 实现组件类

通过实现 CustomElementStart 类来定义组件。

  1. 定义组件

将组件的标签和组件类作为参数,通过 customElements.define 方法定义组件。

  1. 使用组件

导入组件后,跟使用普通 HTML 标签一样直接使用自定义组件 <custom-element-start></custom-element-start>

随后浏览器访问 index.html 可以看到下面内容:
image

3. 兼容性介绍

MDN | Web Components 章节中介绍了其兼容性情况:

  • Firefox(版本63)、Chrome和Opera都默认支持Web组件。
  • Safari支持许多web组件特性,但比上述浏览器少。
  • Edge正在开发一个实现。

关于兼容性,可以看下图:
image
图片来源:https://www.webcomponents.org/

这个网站里面,有很多关于 Web Components 的优秀项目可以学习。

4. 小结

这节主要通过一个简单示例,简单回顾基础知识,详细可以阅读文档:

image

二、EXE-Components 组件库分析设计

1. 背景介绍

假设我们需要实现一个 EXE-Components 组件库,该组件库的组件分 2 大类:

  1. components 类型

通用简单组件为主,如exe-avatar头像组件、 exe-button按钮组件等;

  1. modules 类型

复杂、组合组件为主,如exe-user-avatar用户头像组件(含用户信息)、exe-attachement-list附件列表组件等等。

详细可以看下图:

image

接下来我们会基于上图进行 EXE-Components 组件库设计和开发。

2. 组件库设计

在设计组件库的时候,主要需要考虑以下几点:

  1. 组件命名、参数命名等规范,方便组件后续维护;
  2. 组件参数定义;
  3. 组件样式隔离;

当然,这几个是最基础需要考虑的点,随着实际业务的复杂,还需要考虑更多,比如:工程化相关、组件解耦、组件主题等等。

针对前面提到这 3 点,这边约定几个命名规范:

  1. 组件名称以 exe-功能名称 进行命名,如 exe-avatar表示头像组件;
  2. 属性参数名称以 e-参数名称 进行命名,如 e-src 表示 src 地址属性;
  3. 事件参数名称以 on-事件类型 进行命名,如 on-click表示点击事件;

3. 组件库组件设计

这边我们主要设计 exe-avatarexe-buttonexe-user-avatar三个组件,前两个为简单组件,后一个为复杂组件,其内部使用了前两个组件进行组合。这边先定义这三个组件支持的属性:

image

这边属性命名看着会比较复杂,大家可以按照自己和团队的习惯进行命名。

这样我们思路就清晰很多,实现对应组件即可。

三、EXE-Components 组件库准备工作

本文示例最终将对实现的组件进行组合使用,实现下面「用户列表」效果:

image

体验地址:https://blog.pingan8787.com/exe-components/demo.html

1. 统一开发规范

首先我们先统一开发规范,包括:

  1. 目录规范

image

  1. 定义组件规范

image

  1. 组件开发模版

组件开发模版分 index.js组件入口文件template.js 组件 HTML 模版文件

// index.js 模版
const defaultConfig = {
    // 组件默认配置
}

const Selector = "exe-avatar"; // 组件标签名

export default class EXEAvatar extends HTMLElement {
    shadowRoot = null;
    config = defaultConfig;

    constructor(){
        super();
        this.render(); // 统一处理组件初始化逻辑
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'closed'});
        this.shadowRoot.innerHTML = renderTemplate(this.config);
    }
}

// 定义组件
if (!customElements.get(Selector)) {
    customElements.define(Selector, EXEAvatar)
}
// template.js 模版

export default config => {
    // 统一读取配置
    const { avatarWidth, avatarRadius, avatarSrc } = config;
    return `
        <style>
            /* CSS 内容 */
        </style>
        <div class="exe-avatar">
            /* HTML 内容 */
        </div>
    `
}

2. 开发环境搭建和工程化处理

为了方便使用 EXE-Components 组件库,更接近实际组件库的使用,我们需要将组件库打包成一个 UMD 类型的 js 文件。这边我们使用 rollup 进行构建,最终打包成 exe-components.js 的文件,使用方式如下:

<script src="./exe-components.js"></script>

接下来通过 npm init -y生成 package.json文件,然后全局安装 rollup 和 http-server(用来启动本地服务器,方便调试):

npm init -y
npm install --global rollup http-server

然后在 package.jsonscript 下添加 "dev""build"脚本:

{
	// ...
  "scripts": {
    "dev": "http-server -c-1 -p 1400",
    "build": "rollup index.js --file exe-components.js --format iife"
  },
}

其中:

  • "dev" 命令:通过 http-server 启动静态服务器,作为开发环境使用。添加 -c-1 参数用来禁用缓存,避免刷新页面还会有缓存,详细可以看 http-server 文档
  • "build"命令:将 index.js 作为 rollup 打包的入口文件,输出 exe-components.js 文件,并且是 iife 类型的文件。

这样就完成简单的本地开发和组件库构建的工程化配置,接下来就可以进行开发了。

四、EXE-Components 组件库开发

1. 组件库入口文件配置

前面 package.json 文件中配置的 "build" 命令,会使用根目录下 index.js 作为入口文件,并且为了方便 components 通用基础组件和 modules 通用复杂组件的引入,我们创建 3 个 index.js,创建后目录结构如下:

image

三个入口文件内容分别如下:

// EXE-Components/index.js
import './components/index.js';
import './modules/index.js';

// EXE-Components/components/index.js
import './exe-avatar/index.js';
import './exe-button/index.js';

// EXE-Components/modules/index.js
import './exe-attachment-list/index.js.js';
import './exe-comment-footer/index.js.js';
import './exe-post-list/index.js.js';
import './exe-user-avatar/index.js';

2. 开发 exe-avatar 组件 index.js 文件

通过前面的分析,我们可以知道 exe-avatar组件需要支持参数:

  • e-avatar-src:头像图片地址,例如:./testAssets/images/avatar-1.png
  • e-avatar-width:头像宽度,默认和高度一致,例如:52px
  • e-button-radius:头像圆角,例如:22px,默认:50%
  • on-avatar-click:头像点击事件,默认无

接着按照之前的模版,开发入口文件 index.js

// EXE-Components/components/exe-avatar/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';

const { getAttributes } = Shared;
const { isStr, runFun } = Utils;

const defaultConfig = {
    avatarWidth: "40px",
    avatarRadius: "50%",
    avatarSrc: "./assets/images/default_avatar.png",
    onAvatarClick: null,
}

const Selector = "exe-avatar";

export default class EXEAvatar extends HTMLElement {
    shadowRoot = null;
    config = defaultConfig;

    constructor(){
        super();
        this.render();
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'closed'});
        this.shadowRoot.innerHTML = renderTemplate(this.config);// 生成 HTML 模版内容
    }

		// 生命周期:当 custom element首次被插入文档DOM时,被调用。
    connectedCallback() {
        this.updateStyle();
        this.initEventListen();
    }

    updateStyle() {
        this.config = {...defaultConfig, ...getAttributes(this)};
        this.shadowRoot.innerHTML = renderTemplate(this.config); // 生成 HTML 模版内容
    }

    initEventListen() {
        const { onAvatarClick } = this.config;
        if(isStr(onAvatarClick)){ // 判断是否为字符串
            this.addEventListener('click', e => runFun(e, onAvatarClick));
        }
    }
}

if (!customElements.get(Selector)) {
    customElements.define(Selector, EXEAvatar)
}

其中有几个方法是抽取出来的公用方法,大概介绍下其作用,具体可以看源码:

  • renderTemplate 方法

来自 template.js 暴露的方法,传入配置 config,来生成 HTML 模版。

  • getAttributes 方法

传入一个 HTMLElement 元素,返回该元素上所有属性键值对,其中会对 e-on- 开头的属性,分别处理成普通属性和事件属性,示例如下:

// input
<exe-avatar
    e-avatar-src="./testAssets/images/avatar-1.png"
    e-avatar-width="52px"
    e-avatar-radius="22px"
    on-avatar-click="avatarClick()"
></exe-avatar>
  
// output
{
  avatarSrc: "./testAssets/images/avatar-1.png",
  avatarWidth: "52px",
  avatarRadius: "22px",
  avatarClick: "avatarClick()"
}
  • runFun方法

由于通过属性传递进来的方法,是个字符串,所以进行封装,传入 event 和事件名称作为参数,调用该方法,示例和上一步一样,会执行 avatarClick() 方法。

另外,Web Components 生命周期可以详细看文档:使用生命周期回调函数

3. 开发 exe-avatar 组件 template.js 文件

该文件暴露一个方法,返回组件 HTML 模版:

// EXE-Components/components/exe-avatar/template.js
export default config => {
  const { avatarWidth, avatarRadius, avatarSrc } = config;
  return `
    <style>
      .exe-avatar {
        width: ${avatarWidth};
        height: ${avatarWidth};
        display: inline-block;
        cursor: pointer;
      }
      .exe-avatar .img {
        width: 100%;
        height: 100%;
        border-radius: ${avatarRadius};
        border: 1px solid #efe7e7;
      }
    </style>
    <div class="exe-avatar">
      <img class="img" src="${avatarSrc}" />
    </div>
  `
}

最终实现效果如下:

image

开发完第一个组件,我们可以简单总结一下创建和使用组件的步骤:

image

4. 开发 exe-button 组件

按照前面 exe-avatar组件开发思路,可以很快实现 exe-button 组件。
需要支持下面参数:

  • e-button-radius:按钮圆角,例如:8px
  • e-button-type:按钮类型,例如:default, primary, text, dashed
  • e-button-text:按钮文本,默认:打开
  • on-button-click:按钮点击事件,默认无
// EXE-Components/components/exe-button/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';

const { getAttributes } = Shared;
const { isStr, runFun } = Utils;
const defaultConfig = {
    buttonRadius: "6px",
    buttonPrimary: "default",
    buttonText: "打开",
    disableButton: false,
    onButtonClick: null,
}

const Selector = "exe-button";

export default class EXEButton extends HTMLElement {
    // 指定观察到的属性变化,attributeChangedCallback 会起作用
    static get observedAttributes() { 
        return ['e-button-type','e-button-text', 'buttonType', 'buttonText']
    }

    shadowRoot = null;
    config = defaultConfig;

    constructor(){
        super();
        this.render();
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'closed'});
    }

    connectedCallback() {
        this.updateStyle();
        this.initEventListen();
    }

    attributeChangedCallback (name, oldValue, newValue) {
        // console.log('属性变化', name)
    }

    updateStyle() {
        this.config = {...defaultConfig, ...getAttributes(this)};
        this.shadowRoot.innerHTML = renderTemplate(this.config);
    }

    initEventListen() {
        const { onButtonClick } = this.config;
        if(isStr(onButtonClick)){
            const canClick = !this.disabled && !this.loading
            this.addEventListener('click', e => canClick && runFun(e, onButtonClick));
        }
    }

    get disabled () {
        return this.getAttribute('disabled') !== null;
    }

    get type () {
        return this.getAttribute('type') !== null;
    }

    get loading () {
        return this.getAttribute('loading') !== null;
    }
}

if (!customElements.get(Selector)) {
    customElements.define(Selector, EXEButton)
}

模版定义如下:

// EXE-Components/components/exe-button/tempalte.js
// 按钮边框类型
const borderStyle = { solid: 'solid', dashed: 'dashed' };

// 按钮类型
const buttonTypeMap = {
    default: { textColor: '#222', bgColor: '#FFF', borderColor: '#222'},
    primary: { textColor: '#FFF', bgColor: '#5FCE79', borderColor: '#5FCE79'},
    text: { textColor: '#222', bgColor: '#FFF', borderColor: '#FFF'},
}

export default config => {
    const { buttonRadius, buttonText, buttonType } = config;

    const borderStyleCSS = buttonType 
        && borderStyle[buttonType] 
        ? borderStyle[buttonType] 
        : borderStyle['solid'];

    const backgroundCSS = buttonType 
        && buttonTypeMap[buttonType] 
        ? buttonTypeMap[buttonType] 
        : buttonTypeMap['default'];

    return `
        <style>
            .exe-button {
                border: 1px ${borderStyleCSS} ${backgroundCSS.borderColor};
                color: ${backgroundCSS.textColor};
                background-color: ${backgroundCSS.bgColor};
                font-size: 12px;
                text-align: center;
                padding: 4px 10px;
                border-radius: ${buttonRadius};
                cursor: pointer;
                display: inline-block;
                height: 28px;
            }
            :host([disabled]) .exe-button{ 
                cursor: not-allowed; 
                pointer-events: all; 
                border: 1px solid #D6D6D6;
                color: #ABABAB;
                background-color: #EEE;
            }
            :host([loading]) .exe-button{ 
                cursor: not-allowed; 
                pointer-events: all; 
                border: 1px solid #D6D6D6;
                color: #ABABAB;
                background-color: #F9F9F9;
            }
        </style>
        <button class="exe-button">${buttonText}</button>
    `
}

最终效果如下:

image

5. 开发 exe-user-avatar 组件

该组件是将前面 exe-avatar 组件和 exe-button 组件进行组合,不仅需要支持点击事件,还需要支持插槽 slot 功能

由于是做组合,所以开发起来比较简单~先看看入口文件:

// EXE-Components/modules/exe-user-avatar/index.js

import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';

const { getAttributes } = Shared;
const { isStr, runFun } = Utils;

const defaultConfig = {
    userName: "",
    subName: "",
    disableButton: false,
    onAvatarClick: null,
    onButtonClick: null,
}

export default class EXEUserAvatar extends HTMLElement {
    shadowRoot = null;
    config = defaultConfig;

    constructor() {
        super();
        this.render();
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'open'});
    }

    connectedCallback() {
        this.updateStyle();
        this.initEventListen();
    }

    initEventListen() {
        const { onAvatarClick } = this.config;
        if(isStr(onAvatarClick)){
            this.addEventListener('click', e => runFun(e, onAvatarClick));
        }
    }

    updateStyle() {
        this.config = {...defaultConfig, ...getAttributes(this)};
        this.shadowRoot.innerHTML = renderTemplate(this.config);
    }
}

if (!customElements.get('exe-user-avatar')) {
    customElements.define('exe-user-avatar', EXEUserAvatar)
}

主要内容在 template.js 中:

// EXE-Components/modules/exe-user-avatar/template.js

import { Shared } from '../../utils/index.js';

const { renderAttrStr } = Shared;

export default config => {
    const { 
        userName, avatarWidth, avatarRadius, buttonRadius, 
        avatarSrc, buttonType = 'primary', subName, buttonText, disableButton,
        onAvatarClick, onButtonClick
    } = config;
    return `
        <style>
            :host{
                color: "green";
                font-size: "30px";
            }
            .exe-user-avatar {
                display: flex;
                margin: 4px 0;
            }
            .exe-user-avatar-text {
                font-size: 14px;
                flex: 1;
            }
            .exe-user-avatar-text .text {
                color: #666;
            }
            .exe-user-avatar-text .text span {
                display: -webkit-box;
                -webkit-box-orient: vertical;
                -webkit-line-clamp: 1;
                overflow: hidden;
            }
            exe-avatar {
                margin-right: 12px;
                width: ${avatarWidth};
            }
            exe-button {
                width: 60px;
                display: flex;
                justify-content: end;
            }
        </style>
        <div class="exe-user-avatar">
            <exe-avatar
                ${renderAttrStr({
                    'e-avatar-width': avatarWidth,
                    'e-avatar-radius': avatarRadius,
                    'e-avatar-src': avatarSrc,
                })}
            ></exe-avatar>
            <div class="exe-user-avatar-text">
                <div class="name">
                    <span class="name-text">${userName}</span>
                    <span class="user-attach">
                        <slot name="name-slot"></slot>
                    </span>
                </div>
                <div class="text">
                    <span class="name">${subName}<slot name="sub-name-slot"></slot></span>
                </div>
            </div>
            ${
                !disableButton && 
                `<exe-button
                    ${renderAttrStr({
                        'e-button-radius' : buttonRadius,
                        'e-button-type' : buttonType,
                        'e-button-text' : buttonText,
                        'on-avatar-click' : onAvatarClick,
                        'on-button-click' : onButtonClick,
                    })}
                ></exe-button>`
            }

        </div>
    `
}

其中 renderAttrStr 方法接收一个属性对象,返回其键值对字符串:

// input
{
  'e-avatar-width': 100,
  'e-avatar-radius': 50,
  'e-avatar-src': './testAssets/images/avatar-1.png',
}
  
// output
"e-avatar-width='100' e-avatar-radius='50' e-avatar-src='./testAssets/images/avatar-1.png' "

最终效果如下:

image

6. 实现一个用户列表业务

接下来我们通过一个实际业务,来看看我们组件的效果:

image
其实实现也很简单,根据给定数据,然后循环使用组件即可,假设有以下用户数据:

const users = [
  {"name":"前端早早聊","desc":"帮 5000 个前端先跑 @ 前端早早聊","level":6,"avatar":"qdzzl.jpg","home":"https://juejin.cn/user/712139234347565"}
  {"name":"来自拉夫德鲁的码农","desc":"谁都不救我,谁都救不了我,就像我救不了任何人一样","level":2,"avatar":"lzlfdldmn.jpg","home":"https://juejin.cn/user/994371074524862"}
  {"name":"黑色的枫","desc":"永远怀着一颗学徒的心。。。","level":3,"avatar":"hsdf.jpg","home":"https://juejin.cn/user/2365804756348103"}
  {"name":"captain_p","desc":"目的地很美好,路上的风景也很好。今天增长见识了吗","level":2,"avatar":"cap.jpg","home":"https://juejin.cn/user/2532902235026439"}
  {"name":"CUGGZ","desc":"文章联系微信授权转载。微信:CUG-GZ,添加好友一起学习~","level":5,"avatar":"cuggz.jpg","home":"https://juejin.cn/user/3544481220801815"}
  {"name":"政采云前端团队","desc":"政采云前端 ZooTeam 团队,不掺水的原创。 团队站点:https://zoo.team","level":6,"avatar":"zcy.jpg","home":"https://juejin.cn/user/3456520257288974"}
]

我们就可以通过简单 for 循环拼接 HTML 片段,然后添加到页面某个元素中:

// 测试生成用户列表模版
const usersTemp = () => {
    let temp = '', code = '';
    users.forEach(item => {
        const {name, desc, level, avatar, home} = item;
        temp += 
`
<exe-user-avatar 
    e-user-name="${name}"
    e-sub-name="${desc}"
    e-avatar-src="./testAssets/images/users/${avatar}"
    e-avatar-width="36px"
    e-button-type="primary"
    e-button-text="关注"
    on-avatar-click="toUserHome('${home}')"
    on-button-click="toUserFollow('${name}')"
>
${
    level >= 0 && `<span slot="name-slot">
        <span class="medal-item">(Lv${level})</span>
    </span>`}
</exe-user-avatar>
`
})
    return temp;
}

document.querySelector('#app').innerHTML = usersTemp;

到这边我们就实现了一个用户列表的业务,当然实际业务可能会更加复杂,需要再优化。

五、总结

本文首先简单回顾 Web Components 核心 API,然后对组件库需求进行分析设计,再进行环境搭建和开发,内容比较多,可能没有每一点都讲到,还请大家看看我仓库的源码,有什么问题欢迎和我讨论。

写本文的几个核心目的:

  1. 当我们接到一个新任务的时候,需要从分析设计开始,再到开发,而不是盲目一上来就开始开发;
  2. 带大家一起看看如何用 Web Components 开发简单的业务组件库;
  3. 体验一下 Web Components 开发组件库有什么缺点(就是要写的东西太多了)。

最后看完本文,大家是否觉得用 Web Components 开发组件库,实在有点复杂?要写的太多了。
没关系,下一篇我将带大家一起使用 Stencil 框架开发 Web Components 标准的组件库,毕竟整个 ionic 已经是使用 Stencil 重构,Web Components 大势所趋~!

拓展阅读

《Cute-JavaScript》系列文章勘误登记

记录一些发现到的错误,后面一起修改。

提交模版格式:

  • 错误地址或链接:

如: http://js.pingan8787.com

  • 错误内容:

错误内容描述,如标题错别字。

  • 正确内容:

正确内容描述。

  • 错误影响范围:

需要修改的范围,如:github/各个专栏/博客/Vuepress

说明:
我会利用空余时间做修改,感谢各位指点。
当修改完成后,需要对本条记录做状态修改,添加一个如下👍的表情,表示本条备忘修改完成。

how join

i m a froend build small five(我是个做前端开发的小废物

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.