Shio

Shio 查看完整档案

深圳编辑中国人民解放军理工大学  |  计算机科学与技术 编辑  |  填写所在公司/组织 shiolife.cn/ 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

Shio 关注了用户 · 7月28日

美团技术团队 @meituanjishutuandui

一个只分享有价值技术干货的微信公众号

美团技术团队会定期推送来自一线的实践技术文章,涵盖前端(Web、iOS和Android)、后台、大数据、AI/算法、测试、运维等技术领域。 在这里,与8000多业界一流工程师交流切磋。

关注 4803

Shio 收藏了文章 · 7月24日

AST抽象语法树——最基础的javascript重点知识,99%的人根本不了解

抽象语法树(AST),是一个非常基础而重要的知识点,但国内的文档却几乎一片空白。

本文将带大家从底层了解AST,并且通过发布一个小型前端工具,来带大家了解AST的强大功能

Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。

我们对javascript生态了如指掌,却常忽视javascript本身。这台机器,究竟是哪些零部件在支持着它运行?

AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。

事实上,在javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。

人生第一次拆解Javascript

小时候,当我们拿到一个螺丝刀和一台机器,人生中最令人怀念的梦幻时刻便开始了:

我们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一起...

当我们把它重新照不同的方式组装起来,这时,机器重新又跑动了起来——世界在你眼中如获新生。

image

通过抽象语法树解析,我们可以像童年时拆解玩具一样,透视Javascript这台机器的运转,并且重新按着你的意愿来组装。

现在,我们拆解一个简单的add函数

function add(a, b) {
    return a + b
}

首先,我们拿到的这个语法块,是一个FunctionDeclaration(函数定义)对象。

用力拆开,它成了三块:

  • 一个id,就是它的名字,即add
  • 两个params,就是它的参数,即[a, b]
  • 一块body,也就是大括号内的一堆东西

add没办法继续拆下去了,它是一个最基础Identifier(标志)对象,用来作为函数的唯一标志,就像人的姓名一样。

{
    name: 'add'
    type: 'identifier'
    ...
}

params继续拆下去,其实是两个Identifier组成的数组。之后也没办法拆下去了。

[
    {
        name: 'a'
        type: 'identifier'
        ...
    },
    {
        name: 'b'
        type: 'identifier'
        ...
    }
]

接下来,我们继续拆开body
我们发现,body其实是一个BlockStatement(块状域)对象,用来表示是{return a + b}

打开Blockstatement,里面藏着一个ReturnStatement(Return域)对象,用来表示return a + b

继续打开ReturnStatement,里面是一个BinaryExpression(二项式)对象,用来表示a + b

继续打开BinaryExpression,它成了三部分,leftoperatorright

  • operator+
  • left 里面装的,是Identifier对象 a
  • right 里面装的,是Identifer对象 b

就这样,我们把一个简单的add函数拆解完毕,用图表示就是

image

看!抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。

那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?

请查看 AST对象文档

送给你的AST螺丝刀:recast

输入命令:

npm i recast -S

你即可获得一把操纵语法树的螺丝刀

接下来,你可以在任意js文件下操纵这把螺丝刀,我们新建一个parse.js示意:

parse.js

// 给你一把"螺丝刀"——recast
const recast = require("recast");

// 你的"机器"——一段代码
// 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
const code =
  `
  function add(a, b) {
    return a +
      // 有什么奇怪的东西混进来了
      b
  }
  `
// 用螺丝刀解析机器
const ast = recast.parse(code);

// ast可以处理很巨大的代码文件
// 但我们现在只需要代码块的第一个body,即add函数
const add  = ast.program.body[0]

console.log(add)

输入node parse.js你可以查看到add函数的结构,与之前所述一致,通过AST对象文档可查到它的具体属性:

FunctionDeclaration{
    type: 'FunctionDeclaration',
    id: ...
    params: ...
    body: ...
}

你也可以继续使用console.log透视它的更内层,如:

console.log(add.params[0])
console.log(add.body.body[0].argument.left)

recast.types.builders 制作模具

一个机器,你只会拆开重装,不算本事。

拆开了,还能改装,才算上得了台面。

recast.types.builders里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

最简单的例子,我们想把之前的function add(a, b){...}声明,改成匿名函数式声明const add = function(a ,b){...}

如何改装?

第一步,我们创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。

第二步,创建一个VariableDeclarator,放置add.id在左边, 右边是将创建的FunctionDeclaration对象

第三步,我们创建一个FunctionDeclaration,如前所述的三个组件,id params body中,因为是匿名函数id设为空,params使用add.params,body使用add.body。

这样,就创建好了const add = function(){}的AST对象。

在之前的parse.js代码之后,加入以下代码

// 引入变量声明,变量符号,函数声明三种“模具”
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders

// 将准备好的组件置入模具,并组装回原来的ast对象。
ast.program.body[0] = variableDeclaration("const", [
  variableDeclarator(add.id, functionExpression(
    null, // Anonymize the function expression.
    add.params,
    add.body
  ))
]);

//将AST对象重新转回可以阅读的代码
const output = recast.print(ast).code;

console.log(output)

可以看到,我们打印出了

const add = function(a, b) {
  return a +
    // 有什么奇怪的东西混进来了
    b
};

最后一行

const output = recast.print(ast).code;

其实是recast.parse的逆向过程,具体公式为

recast.print(recast.parse(source)).code === source

打印出来还保留着“原装”的函数内容,连注释都没有变。

我们其实也可以打印出美化格式的代码段:

const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

输出为

const add = function(a, b) {
  return a + b;
};
现在,你是不是已经产生了“我可以通过AST树生成任何js代码”的幻觉?

我郑重告诉你,这不是幻觉。

实战进阶:命令行修改js文件

除了parse/print/builder以外,Recast的三项主要功能:

  • run: 通过命令行读取js文件,并转化成ast以供处理。
  • tnt: 通过assert()和check(),可以验证ast对象的类型。
  • visit: 遍历ast树,获取有效的AST对象并进行更改。

我们通过一个系列小务来学习全部的recast工具库:

创建一个用来示例文件,假设是demo.js

demo.js

function add(a, b) {
  return a + b
}

function sub(a, b) {
  return a - b
}

function commonDivision(a, b) {
  while (b !== 0) {
    if (a > b) {
      a = sub(a, b)
    } else {
      b = sub(b, a)
    }
  }
  return a
}

recast.run —— 命令行文件读取

新建一个名为read.js的文件,写入
read.js

recast.run( function(ast, printSource){
    printSource(ast)
})

命令行输入

node read demo.js

我们查以看到js文件内容打印在了控制台上。

我们可以知道,node read可以读取demo.js文件,并将demo.js内容转化为ast对象。

同时它还提供了一个printSource函数,随时可以将ast的内容转换回源码,以方便调试。

recast.visit —— AST节点遍历

read.js

#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function({node}) {
        console.log(node)
        return false
      }
    });
});

recast.visit将AST对象内的节点进行逐个遍历。

注意

  • 你想操作函数声明,就使用visitFunctionDelaration遍历,想操作赋值表达式,就使用visitExpressionStatement。 只要在 AST对象文档中定义的对象,在前面加visit,即可遍历。
  • 通过node可以取到AST对象
  • 每个遍历函数后必须加上return false,或者选择以下写法,否则报错:
#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        printSource(node)
        this.traverse(path)
      }
    })
});

调试时,如果你想输出AST对象,可以console.log(node)

如果你想输出AST对象对应的源码,可以printSource(node)

命令行输入`
node read demo.js`进行测试。

#!/usr/bin/env node 在所有使用recast.run()的文件顶部都需要加入这一行,它的意义我们最后再讨论。

TNT —— 判断AST对象类型

TNT,即recast.types.namedTypes,就像它的名字一样火爆,它用来判断AST对象是否为指定的类型。

TNT.Node.assert(),就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)

TNT.Node.check(),则可以判断类型是否一致,并输出False和True

上述Node可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.value
        // 判断是否为ExpressionStatement,正确则输出一行字。
        if(TNT.ExpressionStatement.check(node)){
          console.log('这是一个ExpressionStatement')
        }
        this.traverse(path);
      }
    });
});

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
        TNT.ExpressionStatement.assert(node)
        this.traverse(path);
      }
    });
});

命令行输入`
node read demo.js`进行测试。

实战:用AST修改源码,导出全部方法

exportific.js

现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如

function add (a, b) {
    return a + b
}

想改变为

exports.add = (a, b) => {
  return a + b
}

除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题

查询AST对象文档

首先,我们先用builders凭空实现一个键头函数

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier:id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression,
  blockStatement
} = recast.types.builders

recast.run(function(ast, printSource) {
  // 一个块级域 {}
  console.log('\n\nstep1:')
  printSource(blockStatement([]))

  // 一个键头函数 ()=>{}
  console.log('\n\nstep2:')
  printSource(arrowFunctionExpression([],blockStatement([])))

  // add赋值为键头函数  add = ()=>{}
  console.log('\n\nstep3:')
  printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))

  // exports.add赋值为键头函数  exports.add = ()=>{}
  console.log('\n\nstep4:')
  printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
    arrowFunctionExpression([],blockStatement([])))))
});

上面写了我们一步一步推断出exports.add = ()=>{}的过程,从而得到具体的AST结构体。

使用node exportific demo.js运行可查看结果。

接下来,只需要在获得的最终的表达式中,把id('add')替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

另外,我们需要注意,在commonDivision函数内,引用了sub函数,应改写成exports.sub

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

recast.run(function (ast, printSource) {
  // 用来保存遍历到的全部函数名
  let funcIds = []
  recast.types.visit(ast, {
    // 遍历所有的函数定义
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      // 保存函数名
      funcIds.push(funcName.name)
      // 这是上一步推导出来的ast结构体
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      // 将原来函数的ast结构体,替换成推导ast结构体
      path.replace(rep)
      // 停止遍历
      return false
    }
  })


  recast.types.visit(ast, {
    // 遍历所有的函数调用
    visitCallExpression(path){
      const node = path.node;
      // 如果函数调用出现在函数定义中,则修改ast结构
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      // 停止遍历
      return false
    }
  })
  // 打印修改后的ast源码
  printSource(ast)
})

一步到位,发一个最简单的exportific前端工具

上面讲了那么多,仍然只体现在理论阶段。

但通过简单的改写,就能通过recast制作成一个名为exportific的源码编辑工具。

以下代码添加作了两个小改动

  1. 添加说明书--help,以及添加了--rewrite模式,可以直接覆盖文件或默认为导出*.export.js文件。
  2. 将之前代码最后的 printSource(ast)替换成 writeASTFile(ast,filename,rewriteMode)

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

const fs = require('fs')
const path = require('path')
// 截取参数
const options = process.argv.slice(2)

//如果没有参数,或提供了-h 或--help选项,则打印帮助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
  console.log(`
    采用commonjs规则,将.js文件内所有函数修改为导出形式。

    选项: -r  或 --rewrite 可直接覆盖原有文件
    `)
  process.exit(0)
}

// 只要有-r 或--rewrite参数,则rewriteMode为true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')

// 获取文件名
const clearFileArg = options.filter((item)=>{
  return !['-r','--rewrite','-h','--help'].includes(item)
})

// 只处理一个文件
let filename = clearFileArg[0]

const writeASTFile = function(ast, filename, rewriteMode){
  const newCode = recast.print(ast).code
  if(!rewriteMode){
    // 非覆盖模式下,将新文件写入*.export.js下
    filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
  }
  // 将新代码写入文件
  fs.writeFileSync(path.join(process.cwd(),filename),newCode)
}


recast.run(function (ast, printSource) {
  let funcIds = []
  recast.types.visit(ast, {
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      funcIds.push(funcName.name)
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      path.replace(rep)
      return false
    }
  })


  recast.types.visit(ast, {
    visitCallExpression(path){
      const node = path.node;
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      return false
    }
  })

  writeASTFile(ast,filename,rewriteMode)
})

现在尝试一下

node exportific demo.js

已经可以在当前目录下找到源码变更后的demo.export.js文件了。

npm发包

编辑一下package.json文件

{
  "name": "exportific",
  "version": "0.0.1",
  "description": "改写源码中的函数为可exports.XXX形式",
  "main": "exportific.js",
  "bin": {
    "exportific": "./exportific.js"
  },
  "keywords": [],
  "author": "wanthering",
  "license": "ISC",
  "dependencies": {
    "recast": "^0.15.3"
  }
}

注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js

这时,输入npm link 就在本地生成了一个exportific命令。

之后,只要哪个js文件想导出来使用,就exportific XXX.js一下。

这是在本地的玩法,想和大家一起分享这个前端小工具,只需要发布npm包就行了。

同时,一定要注意exportific.js文件头有

#!/usr/bin/env node

否则在使用时将报错。

接下来,正式发布npm包!

如果你已经有了npm 帐号,请使用npm login登录

如果你还没有npm帐号 https://www.npmjs.com/signup 非常简单就可以注册npm

然后,输入
npm publish

没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人都可以通过

npm i exportific -g

全局安装这一个插件。

提示:==在试验教程时,请不要和我的包重名,修改一下发包名称。==

结语

我们对javascript再熟悉不过,但透过AST的视角,最普通的js语句,却焕发出精心动魄的美感。你可以通过它批量构建任何javascript代码!

童年时,这个世界充满了新奇的玩具,再普通的东西在你眼中都如同至宝。如今,计算机语言就是你手中的大玩具,一段段AST对象的拆分组装,构建出我们所生活的网络世界。

所以不得不说软件工程师是一个幸福的工作,你心中住的仍然是那个午后的少年,永远有无数新奇等你发现,永远有无数梦想等你构建。

github地址:https://github.com/wanthering...

image

查看原文

Shio 收藏了文章 · 7月24日

解析 Webpack中import、require、按需加载的执行过程

最近由于一篇分享手淘过年项目中采用到的前端技术的影响,重新研究了一下项目中CSS的架构.本来打算写一篇文章,但是写到一半突然发现自己像在写文档介绍一样,所以后来就放弃了。但是觉得过程中研究的 Webpack 倒是可以单独拿出来讲一讲

在这里非常感谢印记中文 团队翻译的 Webpack 文档.

搭建一个简单环境

  1. npm init
  2. npm install css-loader html-webpack-plugin style-loader webpack webpack-cli
// Webpack 4.0
const htmlPlugin = require("html-webpack-plugin");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: "[name].js",
    path: __dirname + "/dist"
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: "style-loader"
          },
          {
            loader: "css-loader",
          },
        ]
      }
    ]
  },
  plugins: [
    new htmlPlugin({
      title: "Test Webpack",
      filename: "index.html"
    })
  ]
};

一个基本的配置就搭建好了,详细的配置内容我就不介绍了, 然后我们在 src/index.js 上面写我们的测试代码, 在 dist/main.js 看一下 webpack 实现的原理,那么目前我们的项目结构是这样子的

|-- project
    |-- dist
    |-- src
        |-- index.js
    |-- node_modules
    |-- webpack.config.js

webpack 中 require 和 import 的执行过程

在进入按需加载的讲解之前,我们需要看一个问题 requireimportwebpack 的执行过程是怎样的呢 ?现在我们在 src建立两个文件 index.jsmodule-es6.jsmodule-commonjs.js。我们通过这三个文件解析 requireimport 的执行过程

首先我们要区分的是 CommonJSES6 模块导出之间的区别,在 CommonJS 中你导出模块方式是改变 module.exports,但是对于 ES6 来说并不存在 module 这个变量,他的导出方式是通过一个关键词 export来实现的。在我们书写 JS文件的时候,我们发现无论是以 CommomJS 还是 ES6 的形式导出都可以实现,这是因为 Webpack做了一个兼容处理

我们建立一个小 DEMO 来查看一下,我们现在上面建立的三个文件的代码如下

// index.js
// import moduleDefault, { moduleValue } from "./module-es6.js";
// import moduleDefault, { moduleValue1, moduleValue2 } from "./module-commanjs.js";
// module-es6.js
export let moduleValue = "moduleValue" //ES6模块导出
export default "ModuleDefaultValue"
// module-commonjs.js
exports.moduleValue1 = "moduleValue1"
exports.moduleValue2 = "moduleValue2"

现在我们打开 index.js 中加载 module-commonjs.js 的代码,首先会先给当前模块打上 ES6模块的标识符,在 index 则会产生两个变量 AB. A 保存 module-commonjs 的导出的结果,B 则是兼容 CommonJs中没有 ES6通过 export default导出的结果,其值跟 A一样. 用B来兼容 export default 的结果

然后我们重新注释代码,再打开 index.js 中加载 module-es6.js 的代码

这次和上面一样会先给当前模块打上 ES6模块的标识符,然后去加载 module-es6,获取他的导出值。但是浏览器是不识别 export 这个关键词的所以 Webpack 会对的代码进行解释,首先给 module.exports 设定导出的值,如果是 export default 会直接赋值给 module.exports,如果是其他形式,则给module.exports的导出的key设定一个 getter,该 getter 的返回值就是导出的结果

而对于require来说整个执行过程其实过程和import是一样的。

对于 webpack 来说只要你使用了 import 或者 export等关键字, 他就会给 module.exports添加一个__esModule : true 来识别这是一个 ES6的模块,通过这个值来做一些特殊处理

如果觉得我上面讲的不太明白 那可以看看下面这些代码

let commonjs = {
  "./src/index.js": function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    //给当前模块打上 `ES6`模块的标识符
    __webpack_require__.r(__webpack_exports__); //给当前模块打上 `ES6`模块的标识符

    // 执行 ./src/module-commonjs.js 的代码 获取导出值
    var A = __webpack_require__("./src/module-commonjs.js");

    // 根据 ./src/module-commonjs.js 是否为ES6模块 给返回值增加不同的 getter函数
    var B = __webpack_require__.n(A);
  },
  "./src/module-commonjs.js": function(module, exports) {
    exports.moduleValue1 = "moduleValue1";
    exports.moduleValue2 = "moduleValue2";
  }
};

let es6 = {
  "./src/index.js": function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    //给当前模块打上 `ES6`模块的标识符
    __webpack_require__.r(__webpack_exports__);

    // 执行 ./src/module-commonjs.js 的代码 获取导出值
    var A = __webpack_require__("./src/module-es6.js");
  },

  "./src/module-es6.js": function(module, __webpack_exports__, __webpack_require__) {
    //给当前模块打上 `ES6`模块的标识符
    __webpack_require__.r(__webpack_exports__);

    // 设置 __webpack_exports__.moduleValue 的 getter
    __webpack_require__.d(__webpack_exports__, "moduleValue", function() {
      return moduleValue;z
    });

    __webpack_exports__["default"] = "ModuleDefaultValue";

    let moduleValue = "moduleValue";
  }
};

按需加载的执行过程

看完上面的 requireimport,我们回到 按需加载 这个执行过程. webpack 的按需加载是通过 import() 或者 require.ensure()来实现的,有些读者可能对于 require.ensure 比较熟悉,所以我们先看看 require.ensure 的执行过程,
现在我们修改建立一个 module-dynamic.js文件,然后修改 index.js文件

这里吐槽一个问题,require.ensure 第一个参数是一个尴尬的存在,写和不写根本没差,如果你填了的这个参数,webpack 会帮你把文件加载近来,但是不执行。一堆不执行的代码是没有意义的,你想让他执行就必须 require() 一遍,但是执行 require 也会帮你加载文件。所以根本没差,但是里面可能涉及到模块加载顺序的问题,这块我没深入研究了,因为 require.ensure() 使用的场景越来越小了
// index.js
setTimeout(function() {
  require.ensure([], function() {
    let d = require("./module2")
  });
}, 1000);

// module2.js
module.exports = {
  name : "Jason"
}

执行 require.ensure(dependencies,callback,errorCallback,chunkName) 实际上会返回一个 promise , 里面的实现逻辑是 先判断 dependencies 是否已经被加载过,如果加载过则取缓存值的 promise, 如果没有被加载过 则生成一个 promise 并将 promise 里面的 resolve,rejectpromise本身 存入一个数组,然后缓存起来.接着生成一个 script 标签,填充完信息之后添加到HTML文件上,其中的 scriptsrc属性 就是我们按需加载的文件(module2),webpack 会对这个 script 标签监听 errorload时间,从而做相应的处理。

webpack打包过程中会给 module2 添加一些代码,主要就是主动触发 window["webpackJsonp"].push这个函数,这个函数会传递
两个参数 文件ID文件内容对象,其中 文件标示如果没有配置的话,会按载入序号自动增长,文件内容对象实际上就是上文说的 require.ensure第一个参数dependencies的文件内容,或者是 callback,errorCallback里面需要加载的文件,以 key(文件路径) --- value(文件内容)的形式出现.里面执行的事情其实就是执行上面创建的promiseresolve函数,让require.ensure里面的callback执行,之后的执行情况就跟我上面将 requirimport 一样了

当然其实讲了那么长的 require.ensure并没有什么用,因为这个函数已经被 import() 取代了,但是考虑到之前的版本应该有很多人都是用 require.ensure 方法去加载的,所以还是讲一下,而且其实 import 的执行过程跟 require.ensure 是一样的,只不过用了更友好的语法而已,所以关于 import 的执行流程我也没啥好讲的了,感兴趣的人看一下两者的 API介绍就好了。

到这里就正式讲完了,如果有对此深入的同学路过看到有不对的地方,希望能帮我指出来.非常谢谢!!!

然后再次感谢印记中文 团队翻译的 Webpack 文档

查看原文

Shio 收藏了文章 · 7月24日

彻底搞懂并实现webpack热更新原理

目录

HMR是什么

HMRHot Module Replacement是指当你对代码修改并保存后,webpack将会对代码进行重新打包,并将改动的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,去实现局部更新页面而非整体刷新页面。接下来将从使用到实现一版简易功能带领大家深入浅出HMR

文章首发于@careteen/webpack-hmr,转载请注明来源即可。

使用场景

scenario

如上图所示,一个注册页面包含用户名密码邮箱三个必填输入框,以及一个提交按钮,当你在调试邮箱模块改动了代码时,没做任何处理情况下是会刷新整个页面,频繁的改动代码会浪费你大量时间去重新填写内容。预期是保留用户名密码的输入内容,而只替换邮箱这一模块。这一诉求就需要借助webpack-dev-server的热模块更新功能。

相对于live reload整体刷新页面的方案,HMR的优点在于可以保存应用的状态,提高开发效率。

配置使用HMR

配置webpack

首先借助webpack搭建项目

  • 初识化项目并导入依赖
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
  • 配置文件webpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development', // 开发模式不压缩代码,方便调试
  entry: './src/index.js', // 入口文件
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js'
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist')
  },
  plugins: [
    new htmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html'
    })
  ]
}
  • 新建src/index.html模板文件
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Webpack Hot Module Replacement</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
  • 新建src/index.js入口文件编写简单逻辑
var root = document.getElementById('root')
function render () {
  root.innerHTML = require('./content.js')
}
render()
  • 新建依赖文件src/content.js导出字符供index渲染页面
var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
  • 配置package.json
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  }
  • 然后npm run dev即可启动项目
  • 通过npm run build打包生成静态资源到dist目录

接下来先分析下dist目录中的文件

解析webpack打包后的文件内容

  • webpack自己实现的一套commonjs规范讲解
  • 区分commonjs和esmodule

dist目录结构

.
├── index.html
└── main.js

其中index.html内容如下

<!-- ... -->
<div id="root"></div>
<script type="text/javascript" data-original="main.js"></script></body>
<!-- ... -->

使用html-webpack-plugin插件将入口文件及其依赖通过script标签引入

先对main.js内容去掉注释和无关内容进行分析

(function (modules) { // webpackBootstrap
  // ...
})
({
  "./src/content.js":
    (function (module, exports) {
      eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
    }),
  "./src/index.js": (function (module, exports, __webpack_require__) {
    eval("var root = document.getElementById('root')\nfunction render () {\n  root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
  })
});

可见webpack打包后会产出一个自执行函数,其参数为一个对象

"./src/content.js": (function (module, exports) {
  eval("...")
}

键为入口文件或依赖文件相对于根目录的相对路径,值则是一个函数,其中使用eval执行文件的内容字符。

  • 再进入自执行函数体内,可见webpack自己实现了一套commonjs规范
(function (modules) {
  // 模块缓存
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // 判断是否有缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 没有缓存则创建一个模块对象并将其放入缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加载
      exports: {}
    };
    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 将状态置为已加载
    module.l = true;
    // 返回模块对象
    return module.exports;
  }
  // ...
  // 加载入口文件
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
如果对上面commonjs规范感兴趣可以前往我的另一篇文章手摸手带你实现commonjs规范

给出上面代码主要是先对webpack的产出文件混个眼熟,不要惧怕。其实任何一个不管多复杂的事物都是由更小更简单的东西组成,剖开它认识它爱上它。

配置HMR

接下来配置并感受一下热更新带来的便捷开发

webpack.config.js配置

  // ...
  devServer: {
    hot: true
  }
  // ...

./src/index.js配置

// ...
if (module.hot) {
  module.hot.accept(['./content.js'], () => {
    render()
  })
}

当更改./content.js的内容并保存时,可以看到页面没有刷新,但是内容已经被替换了。

这对提高开发效率意义重大。接下来将一层层剖开它,认识它的实现原理。

HMR原理

core

如上图所示,右侧Server端使用webpack-dev-server去启动本地服务,内部实现主要使用了webpackexpresswebsocket

  • 使用express启动本地服务,当浏览器访问资源时对此做响应。
  • 服务端和客户端使用websocket实现长连接
  • webpack监听源文件的变化,即当开发者保存文件时触发webpack的重新编译。

    • 每次编译都会生成hash值已改动模块的json文件已改动模块代码的js文件
    • 编译完成后通过socket向客户端推送当前编译的hash戳
  • 客户端的websocket监听到有文件改动推送过来的hash戳,会和上一次对比

    • 一致则走缓存
    • 不一致则通过ajaxjsonp向服务端获取最新资源
  • 使用内存文件系统去替换有修改的内容实现局部刷新

上图先只看个大概,下面将从服务端和客户端两个方面进行详细分析

debug服务端源码

core

现在也只需要关注上图的右侧服务端部分,左侧可以暂时忽略。下面步骤主要是debug服务端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。

  1. 启动webpack-dev-server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173
  2. 创建webpack实例,源代码地址@webpack-dev-server/webpack-dev-server.js#L89
  3. 创建Server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L107
  4. 添加webpack的done事件回调,源代码地址@webpack-dev-server/Server.js#L122

    1. 编译完成向客户端发送消息,源代码地址@webpack-dev-server/Server.js#L184
  5. 创建express应用app,源代码地址@webpack-dev-server/Server.js#L123
  6. 设置文件系统为内存文件系统,源代码地址@webpack-dev-middleware/fs.js#L115
  7. 添加webpack-dev-middleware中间件,源代码地址@webpack-dev-server/Server.js#L125

    1. 中间件负责返回生成的文件,源代码地址@webpack-dev-middleware/middleware.js#L20
  8. 启动webpack编译,源代码地址@webpack-dev-middleware/index.js#L51
  9. 创建http服务器并启动服务,源代码地址@webpack-dev-server/Server.js#L135
  10. 使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745

    1. 创建socket服务器,源代码地址@webpack-dev-server/SockJSServer.js#L34

服务端简易实现

上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合到一个文件中

启动webpack-dev-server服务器

先导入所有依赖

const path = require('path') // 解析文件路径
const express = require('express') // 启动本地服务
const mime = require('mime') // 获取文件类型 实现一个静态服务器
const webpack = require('webpack') // 读取配置文件进行打包
const MemoryFileSystem = require('memory-fs') // 使用内存文件系统更快,文件生成在内存中而非真实文件
const config = require('./webpack.config') // 获取webpack配置文件

创建webpack实例

const compiler = webpack(config)

compiler代表整个webpack编译任务,全局只有一个

创建Server服务器

class Server {
  constructor(compiler) {
    this.compiler = compiler
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }
}
let server = new Server(compiler)
server.listen(8000)

在后面是通过express来当启动服务的

添加webpack的done事件回调

  constructor(compiler) {
    let sockets = []
    let lasthash
    compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      lasthash = stats.hash
      // 每当新一个编译完成后都会向客户端发送消息
      sockets.forEach(socket => {
        socket.emit('hash', stats.hash) // 先向客户端发送最新的hash值
        socket.emit('ok') // 再向客户端发送一个ok
      })
    })
  }

webpack编译后提供提供了一系列钩子函数,以供插件能访问到它的各个生命周期节点,并对其打包内容做修改。compiler.hooks.done则是插件能修改其内容的最后一个节点。

编译完成通过socket向客户端发送消息,推送每次编译产生的hash。另外如果是热更新的话,还会产出二个补丁文件,里面描述了从上一次结果到这一次结果都有哪些chunk和模块发生了变化。

使用let sockets = []数组去存放当打开了多个Tab时每个Tab的socket实例

创建express应用app

let app = new express()

设置文件系统为内存文件系统

let fs = new MemoryFileSystem()

使用MemoryFileSystemcompiler的产出文件打包到内存中。

添加webpack-dev-middleware中间件

  function middleware(req, res, next) {
    if (req.url === '/favicon.ico') {
      return res.sendStatus(404)
    }
    // /index.html   dist/index.html
    let filename = path.join(config.output.path, req.url.slice(1))
    let stat = fs.statSync(filename)
    if (stat.isFile()) { // 判断是否存在这个文件,如果在的话直接把这个读出来发给浏览器
      let content = fs.readFileSync(filename)
      let contentType = mime.getType(filename)
      res.setHeader('Content-Type', contentType)
      res.statusCode = res.statusCode || 200
      res.send(content)
    } else {
      return res.sendStatus(404)
    }
  }
  app.use(middleware)

使用expres启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件。

启动webpack编译

  compiler.watch({}, err => {
    console.log('又一次编译任务成功完成了')
  })

以监控的模式启动一次webpack编译,当编译成功之后执行回调

创建http服务器并启动服务

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    // ...
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }

使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    let io = require('socket.io')(this.server)
    io.on('connection', (socket) => {
      sockets.push(socket)
      socket.emit('hash', lastHash)
      socket.emit('ok')
    })
  }

启动一个 websocket服务器,然后等待连接来到,连接到来之后存进sockets池

当有文件改动,webpack重新编译时,向客户端推送hashok两个事件

服务端调试阶段

感兴趣的可以根据上面debug服务端源码所带的源码位置,并在浏览器的调试模式下设置断点查看每个阶段的值。

node dev-server.js

使用我们自己编译的dev-server.js启动服务,可看到页面可以正常展示,但还没有实现热更新。

下面将调式客户端的源代码分析其实现流程。

debug客户端源码

core

现在也只需要关注上图的左侧客户端部分,右侧可以暂时忽略。下面步骤主要是debug客户端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。

debug客户端源码分析其详细思路

  1. webpack-dev-server/client端会监听到此hash消息,源代码地址@webpack-dev-server/index.js#L54
  2. 客户端收到ok的消息后会执行reloadApp方法进行更新,源代码地址index.js#L101
  3. 在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器,源代码地址reloadApp.js#L7
  4. 在webpack/hot/dev-server.js会监听webpackHotUpdate事件,源代码地址dev-server.js#L55
  5. 在check方法里会调用module.hot.check方法,源代码地址dev-server.js#L13
  6. HotModuleReplacement.runtime请求Manifest,源代码地址HotModuleReplacement.runtime.js#L180
  7. 它通过调用 JsonpMainTemplate.runtime的hotDownloadManifest方法,源代码地址JsonpMainTemplate.runtime.js#L23
  8. 调用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码,源代码地址JsonpMainTemplate.runtime.js#L14
  9. 补丁JS取回来后会调用JsonpMainTemplate.runtime.js的webpackHotUpdate方法,源代码地址JsonpMainTemplate.runtime.js#L8
  10. 然后会调用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法动态更新模块代码,源代码地址HotModuleReplacement.runtime.js#L222
  11. 然后调用hotApply方法进行热更新,源代码地址HotModuleReplacement.runtime.js#L257HotModuleReplacement.runtime.js#L278

客户端简易实现

上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合成一个文件

webpack-dev-server/client端会监听到此hash消息

在开发客户端功能之前,需要在src/index.html中引入socket.io

<script data-original="/socket.io/socket.io.js"></script>

下面连接socket并接受消息

let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
  console.log('客户端连接成功')
}
let hotCurrentHash // lastHash 上一次 hash值 
let currentHash // 这一次的hash值
socket.on('hash', (hash) => {
  currentHash = hash
})

将服务端webpack每次编译所产生hash进行缓存

客户端收到ok的消息后会执行reloadApp方法进行更新

socket.on('ok', () => {
  reloadApp(true)
})

reloadApp中判断是否支持热更新

// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
  if (hot) { // 如果hot为true 走热更新的逻辑
    hotEmitter.emit('webpackHotUpdate')
  } else { // 如果不支持热更新,则直接重新加载
    window.location.reload()
  }
}

在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器。

在webpack/hot/dev-server.js会监听webpackHotUpdate事件

首先需要一个发布订阅去绑定事件并在合适的时机触发。

class Emitter {
  constructor() {
    this.listeners = {}
  }
  on(type, listener) {
    this.listeners[type] = listener
  }
  emit(type) {
    this.listeners[type] && this.listeners[type]()
  }
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
  if (!hotCurrentHash || hotCurrentHash == currentHash) {
    return hotCurrentHash = currentHash
  }
  hotCheck()
})

会判断是否为第一次进入页面和代码是否有更新。

上面的发布订阅较为简单,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。

在check方法里会调用module.hot.check方法

function hotCheck() {
  hotDownloadManifest().then(update => {
    let chunkIds = Object.keys(update.c)
    chunkIds.forEach(chunkId => {
      hotDownloadUpdateChunk(chunkId)
    })
  })
}

上面也提到过webpack每次编译都会产生hash值已改动模块的json文件已改动模块代码的js文件

此时先使用ajax请求Manifest即服务器这一次编译相对于上一次编译改变了哪些module和chunk。

然后再通过jsonp获取这些已改动的module和chunk的代码。

调用hotDownloadManifest方法

function hotDownloadManifest() {
  return new Promise(function (resolve) {
    let request = new XMLHttpRequest()
    //hot-update.json文件里存放着从上一次编译到这一次编译 取到差异
    let requestPath = '/' + hotCurrentHash + ".hot-update.json"
    request.open('GET', requestPath, true)
    request.onreadystatechange = function () {
      if (request.readyState === 4) {
        let update = JSON.parse(request.responseText)
        resolve(update)
      }
    }
    request.send()
  })
}

调用hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码

function hotDownloadUpdateChunk(chunkId) {
  let script = document.createElement('script')
  script.charset = 'utf-8'
  // /main.xxxx.hot-update.js
  script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
  document.head.appendChild(script)
}

这里解释下为什么使用JSONP获取而不直接利用socket获取最新代码?主要是因为JSONP获取的代码可以直接执行。

调用webpackHotUpdate方法

当客户端把最新的代码拉到浏览之后

window.webpackHotUpdate = function (chunkId, moreModules) {
  // 循环新拉来的模块
  for (let moduleId in moreModules) {
    // 从模块缓存中取到老的模块定义
    let oldModule = __webpack_require__.c[moduleId]
    // parents哪些模块引用这个模块 children这个模块引用了哪些模块
    // parents=['./src/index.js']
    let {
      parents,
      children
    } = oldModule
    // 更新缓存为最新代码 缓存进行更新
    let module = __webpack_require__.c[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
      parents,
      children,
      hot: window.hotCreateModule(moduleId)
    }
    moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
    module.l = true // 状态变为加载就是给module.exports 赋值了
    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })
    hotCurrentHash = currentHash
  }
}

hotCreateModule的实现

实现我们可以在业务代码中定义需要热更新的模块以及回调函数,将其存放在hot._acceptedDependencies中。

window.hotCreateModule = function () {
  let hot = {
    _acceptedDependencies: {},
    dispose() {
      // 销毁老的元素
    },
    accept: function (deps, callback) {
      for (let i = 0; i < deps.length; i++) {
        // hot._acceptedDependencies={'./title': render}
        hot._acceptedDependencies[deps[i]] = callback
      }
    }
  }
  return hot
}

然后在webpackHotUpdate中进行调用

    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })

最后调用hotApply方法进行热更新

客户端调试阶段

经过上述实现了一个基本版的HMR,可更改代码保存的同时查看浏览器并非整体刷新,而是局部更新代码进而更新视图。在涉及到大量表单的需求时大大提高了开发效率。

问题

  • 如何实现commonjs规范?
感兴趣的可前往debug CommonJs规范了解其实现原理。
  • webpack实现流程以及各个生命周期的作用是什么?
webpack主要借助了tapable这个库所提供的一系列同步/异步钩子函数贯穿整个生命周期。webpack生命周期基于此我实现了一版简易的webpack,源码100+行,食用时伴着注释很容易消化,感兴趣的可前往看个思路。
  • 发布订阅的使用和实现,并且如何实现一个可先订阅后发布的机制?
上面也提到需要使用到发布订阅模式,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
  • 为什么使用JSONP而不用socke通信获取更新过的代码?
因为通过socket通信获取的是一串字符串需要再做处理。而通过JSONP获取的代码可以直接执行。

引用

招聘

急缺前端,对搜狐焦点感兴趣的直接简历发我邮件<ketingwang213821@sohu-inc.com>或加我vx: Careteen

查看原文

Shio 赞了文章 · 7月24日

彻底搞懂并实现webpack热更新原理

目录

HMR是什么

HMRHot Module Replacement是指当你对代码修改并保存后,webpack将会对代码进行重新打包,并将改动的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,去实现局部更新页面而非整体刷新页面。接下来将从使用到实现一版简易功能带领大家深入浅出HMR

文章首发于@careteen/webpack-hmr,转载请注明来源即可。

使用场景

scenario

如上图所示,一个注册页面包含用户名密码邮箱三个必填输入框,以及一个提交按钮,当你在调试邮箱模块改动了代码时,没做任何处理情况下是会刷新整个页面,频繁的改动代码会浪费你大量时间去重新填写内容。预期是保留用户名密码的输入内容,而只替换邮箱这一模块。这一诉求就需要借助webpack-dev-server的热模块更新功能。

相对于live reload整体刷新页面的方案,HMR的优点在于可以保存应用的状态,提高开发效率。

配置使用HMR

配置webpack

首先借助webpack搭建项目

  • 初识化项目并导入依赖
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
  • 配置文件webpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development', // 开发模式不压缩代码,方便调试
  entry: './src/index.js', // 入口文件
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js'
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist')
  },
  plugins: [
    new htmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html'
    })
  ]
}
  • 新建src/index.html模板文件
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Webpack Hot Module Replacement</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
  • 新建src/index.js入口文件编写简单逻辑
var root = document.getElementById('root')
function render () {
  root.innerHTML = require('./content.js')
}
render()
  • 新建依赖文件src/content.js导出字符供index渲染页面
var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
  • 配置package.json
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  }
  • 然后npm run dev即可启动项目
  • 通过npm run build打包生成静态资源到dist目录

接下来先分析下dist目录中的文件

解析webpack打包后的文件内容

  • webpack自己实现的一套commonjs规范讲解
  • 区分commonjs和esmodule

dist目录结构

.
├── index.html
└── main.js

其中index.html内容如下

<!-- ... -->
<div id="root"></div>
<script type="text/javascript" data-original="main.js"></script></body>
<!-- ... -->

使用html-webpack-plugin插件将入口文件及其依赖通过script标签引入

先对main.js内容去掉注释和无关内容进行分析

(function (modules) { // webpackBootstrap
  // ...
})
({
  "./src/content.js":
    (function (module, exports) {
      eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
    }),
  "./src/index.js": (function (module, exports, __webpack_require__) {
    eval("var root = document.getElementById('root')\nfunction render () {\n  root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
  })
});

可见webpack打包后会产出一个自执行函数,其参数为一个对象

"./src/content.js": (function (module, exports) {
  eval("...")
}

键为入口文件或依赖文件相对于根目录的相对路径,值则是一个函数,其中使用eval执行文件的内容字符。

  • 再进入自执行函数体内,可见webpack自己实现了一套commonjs规范
(function (modules) {
  // 模块缓存
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // 判断是否有缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 没有缓存则创建一个模块对象并将其放入缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加载
      exports: {}
    };
    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 将状态置为已加载
    module.l = true;
    // 返回模块对象
    return module.exports;
  }
  // ...
  // 加载入口文件
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
如果对上面commonjs规范感兴趣可以前往我的另一篇文章手摸手带你实现commonjs规范

给出上面代码主要是先对webpack的产出文件混个眼熟,不要惧怕。其实任何一个不管多复杂的事物都是由更小更简单的东西组成,剖开它认识它爱上它。

配置HMR

接下来配置并感受一下热更新带来的便捷开发

webpack.config.js配置

  // ...
  devServer: {
    hot: true
  }
  // ...

./src/index.js配置

// ...
if (module.hot) {
  module.hot.accept(['./content.js'], () => {
    render()
  })
}

当更改./content.js的内容并保存时,可以看到页面没有刷新,但是内容已经被替换了。

这对提高开发效率意义重大。接下来将一层层剖开它,认识它的实现原理。

HMR原理

core

如上图所示,右侧Server端使用webpack-dev-server去启动本地服务,内部实现主要使用了webpackexpresswebsocket

  • 使用express启动本地服务,当浏览器访问资源时对此做响应。
  • 服务端和客户端使用websocket实现长连接
  • webpack监听源文件的变化,即当开发者保存文件时触发webpack的重新编译。

    • 每次编译都会生成hash值已改动模块的json文件已改动模块代码的js文件
    • 编译完成后通过socket向客户端推送当前编译的hash戳
  • 客户端的websocket监听到有文件改动推送过来的hash戳,会和上一次对比

    • 一致则走缓存
    • 不一致则通过ajaxjsonp向服务端获取最新资源
  • 使用内存文件系统去替换有修改的内容实现局部刷新

上图先只看个大概,下面将从服务端和客户端两个方面进行详细分析

debug服务端源码

core

现在也只需要关注上图的右侧服务端部分,左侧可以暂时忽略。下面步骤主要是debug服务端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。

  1. 启动webpack-dev-server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173
  2. 创建webpack实例,源代码地址@webpack-dev-server/webpack-dev-server.js#L89
  3. 创建Server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L107
  4. 添加webpack的done事件回调,源代码地址@webpack-dev-server/Server.js#L122

    1. 编译完成向客户端发送消息,源代码地址@webpack-dev-server/Server.js#L184
  5. 创建express应用app,源代码地址@webpack-dev-server/Server.js#L123
  6. 设置文件系统为内存文件系统,源代码地址@webpack-dev-middleware/fs.js#L115
  7. 添加webpack-dev-middleware中间件,源代码地址@webpack-dev-server/Server.js#L125

    1. 中间件负责返回生成的文件,源代码地址@webpack-dev-middleware/middleware.js#L20
  8. 启动webpack编译,源代码地址@webpack-dev-middleware/index.js#L51
  9. 创建http服务器并启动服务,源代码地址@webpack-dev-server/Server.js#L135
  10. 使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745

    1. 创建socket服务器,源代码地址@webpack-dev-server/SockJSServer.js#L34

服务端简易实现

上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合到一个文件中

启动webpack-dev-server服务器

先导入所有依赖

const path = require('path') // 解析文件路径
const express = require('express') // 启动本地服务
const mime = require('mime') // 获取文件类型 实现一个静态服务器
const webpack = require('webpack') // 读取配置文件进行打包
const MemoryFileSystem = require('memory-fs') // 使用内存文件系统更快,文件生成在内存中而非真实文件
const config = require('./webpack.config') // 获取webpack配置文件

创建webpack实例

const compiler = webpack(config)

compiler代表整个webpack编译任务,全局只有一个

创建Server服务器

class Server {
  constructor(compiler) {
    this.compiler = compiler
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }
}
let server = new Server(compiler)
server.listen(8000)

在后面是通过express来当启动服务的

添加webpack的done事件回调

  constructor(compiler) {
    let sockets = []
    let lasthash
    compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      lasthash = stats.hash
      // 每当新一个编译完成后都会向客户端发送消息
      sockets.forEach(socket => {
        socket.emit('hash', stats.hash) // 先向客户端发送最新的hash值
        socket.emit('ok') // 再向客户端发送一个ok
      })
    })
  }

webpack编译后提供提供了一系列钩子函数,以供插件能访问到它的各个生命周期节点,并对其打包内容做修改。compiler.hooks.done则是插件能修改其内容的最后一个节点。

编译完成通过socket向客户端发送消息,推送每次编译产生的hash。另外如果是热更新的话,还会产出二个补丁文件,里面描述了从上一次结果到这一次结果都有哪些chunk和模块发生了变化。

使用let sockets = []数组去存放当打开了多个Tab时每个Tab的socket实例

创建express应用app

let app = new express()

设置文件系统为内存文件系统

let fs = new MemoryFileSystem()

使用MemoryFileSystemcompiler的产出文件打包到内存中。

添加webpack-dev-middleware中间件

  function middleware(req, res, next) {
    if (req.url === '/favicon.ico') {
      return res.sendStatus(404)
    }
    // /index.html   dist/index.html
    let filename = path.join(config.output.path, req.url.slice(1))
    let stat = fs.statSync(filename)
    if (stat.isFile()) { // 判断是否存在这个文件,如果在的话直接把这个读出来发给浏览器
      let content = fs.readFileSync(filename)
      let contentType = mime.getType(filename)
      res.setHeader('Content-Type', contentType)
      res.statusCode = res.statusCode || 200
      res.send(content)
    } else {
      return res.sendStatus(404)
    }
  }
  app.use(middleware)

使用expres启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件。

启动webpack编译

  compiler.watch({}, err => {
    console.log('又一次编译任务成功完成了')
  })

以监控的模式启动一次webpack编译,当编译成功之后执行回调

创建http服务器并启动服务

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    // ...
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }

使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    let io = require('socket.io')(this.server)
    io.on('connection', (socket) => {
      sockets.push(socket)
      socket.emit('hash', lastHash)
      socket.emit('ok')
    })
  }

启动一个 websocket服务器,然后等待连接来到,连接到来之后存进sockets池

当有文件改动,webpack重新编译时,向客户端推送hashok两个事件

服务端调试阶段

感兴趣的可以根据上面debug服务端源码所带的源码位置,并在浏览器的调试模式下设置断点查看每个阶段的值。

node dev-server.js

使用我们自己编译的dev-server.js启动服务,可看到页面可以正常展示,但还没有实现热更新。

下面将调式客户端的源代码分析其实现流程。

debug客户端源码

core

现在也只需要关注上图的左侧客户端部分,右侧可以暂时忽略。下面步骤主要是debug客户端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。

debug客户端源码分析其详细思路

  1. webpack-dev-server/client端会监听到此hash消息,源代码地址@webpack-dev-server/index.js#L54
  2. 客户端收到ok的消息后会执行reloadApp方法进行更新,源代码地址index.js#L101
  3. 在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器,源代码地址reloadApp.js#L7
  4. 在webpack/hot/dev-server.js会监听webpackHotUpdate事件,源代码地址dev-server.js#L55
  5. 在check方法里会调用module.hot.check方法,源代码地址dev-server.js#L13
  6. HotModuleReplacement.runtime请求Manifest,源代码地址HotModuleReplacement.runtime.js#L180
  7. 它通过调用 JsonpMainTemplate.runtime的hotDownloadManifest方法,源代码地址JsonpMainTemplate.runtime.js#L23
  8. 调用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码,源代码地址JsonpMainTemplate.runtime.js#L14
  9. 补丁JS取回来后会调用JsonpMainTemplate.runtime.js的webpackHotUpdate方法,源代码地址JsonpMainTemplate.runtime.js#L8
  10. 然后会调用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法动态更新模块代码,源代码地址HotModuleReplacement.runtime.js#L222
  11. 然后调用hotApply方法进行热更新,源代码地址HotModuleReplacement.runtime.js#L257HotModuleReplacement.runtime.js#L278

客户端简易实现

上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合成一个文件

webpack-dev-server/client端会监听到此hash消息

在开发客户端功能之前,需要在src/index.html中引入socket.io

<script data-original="/socket.io/socket.io.js"></script>

下面连接socket并接受消息

let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
  console.log('客户端连接成功')
}
let hotCurrentHash // lastHash 上一次 hash值 
let currentHash // 这一次的hash值
socket.on('hash', (hash) => {
  currentHash = hash
})

将服务端webpack每次编译所产生hash进行缓存

客户端收到ok的消息后会执行reloadApp方法进行更新

socket.on('ok', () => {
  reloadApp(true)
})

reloadApp中判断是否支持热更新

// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
  if (hot) { // 如果hot为true 走热更新的逻辑
    hotEmitter.emit('webpackHotUpdate')
  } else { // 如果不支持热更新,则直接重新加载
    window.location.reload()
  }
}

在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器。

在webpack/hot/dev-server.js会监听webpackHotUpdate事件

首先需要一个发布订阅去绑定事件并在合适的时机触发。

class Emitter {
  constructor() {
    this.listeners = {}
  }
  on(type, listener) {
    this.listeners[type] = listener
  }
  emit(type) {
    this.listeners[type] && this.listeners[type]()
  }
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
  if (!hotCurrentHash || hotCurrentHash == currentHash) {
    return hotCurrentHash = currentHash
  }
  hotCheck()
})

会判断是否为第一次进入页面和代码是否有更新。

上面的发布订阅较为简单,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。

在check方法里会调用module.hot.check方法

function hotCheck() {
  hotDownloadManifest().then(update => {
    let chunkIds = Object.keys(update.c)
    chunkIds.forEach(chunkId => {
      hotDownloadUpdateChunk(chunkId)
    })
  })
}

上面也提到过webpack每次编译都会产生hash值已改动模块的json文件已改动模块代码的js文件

此时先使用ajax请求Manifest即服务器这一次编译相对于上一次编译改变了哪些module和chunk。

然后再通过jsonp获取这些已改动的module和chunk的代码。

调用hotDownloadManifest方法

function hotDownloadManifest() {
  return new Promise(function (resolve) {
    let request = new XMLHttpRequest()
    //hot-update.json文件里存放着从上一次编译到这一次编译 取到差异
    let requestPath = '/' + hotCurrentHash + ".hot-update.json"
    request.open('GET', requestPath, true)
    request.onreadystatechange = function () {
      if (request.readyState === 4) {
        let update = JSON.parse(request.responseText)
        resolve(update)
      }
    }
    request.send()
  })
}

调用hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码

function hotDownloadUpdateChunk(chunkId) {
  let script = document.createElement('script')
  script.charset = 'utf-8'
  // /main.xxxx.hot-update.js
  script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
  document.head.appendChild(script)
}

这里解释下为什么使用JSONP获取而不直接利用socket获取最新代码?主要是因为JSONP获取的代码可以直接执行。

调用webpackHotUpdate方法

当客户端把最新的代码拉到浏览之后

window.webpackHotUpdate = function (chunkId, moreModules) {
  // 循环新拉来的模块
  for (let moduleId in moreModules) {
    // 从模块缓存中取到老的模块定义
    let oldModule = __webpack_require__.c[moduleId]
    // parents哪些模块引用这个模块 children这个模块引用了哪些模块
    // parents=['./src/index.js']
    let {
      parents,
      children
    } = oldModule
    // 更新缓存为最新代码 缓存进行更新
    let module = __webpack_require__.c[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
      parents,
      children,
      hot: window.hotCreateModule(moduleId)
    }
    moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
    module.l = true // 状态变为加载就是给module.exports 赋值了
    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })
    hotCurrentHash = currentHash
  }
}

hotCreateModule的实现

实现我们可以在业务代码中定义需要热更新的模块以及回调函数,将其存放在hot._acceptedDependencies中。

window.hotCreateModule = function () {
  let hot = {
    _acceptedDependencies: {},
    dispose() {
      // 销毁老的元素
    },
    accept: function (deps, callback) {
      for (let i = 0; i < deps.length; i++) {
        // hot._acceptedDependencies={'./title': render}
        hot._acceptedDependencies[deps[i]] = callback
      }
    }
  }
  return hot
}

然后在webpackHotUpdate中进行调用

    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })

最后调用hotApply方法进行热更新

客户端调试阶段

经过上述实现了一个基本版的HMR,可更改代码保存的同时查看浏览器并非整体刷新,而是局部更新代码进而更新视图。在涉及到大量表单的需求时大大提高了开发效率。

问题

  • 如何实现commonjs规范?
感兴趣的可前往debug CommonJs规范了解其实现原理。
  • webpack实现流程以及各个生命周期的作用是什么?
webpack主要借助了tapable这个库所提供的一系列同步/异步钩子函数贯穿整个生命周期。webpack生命周期基于此我实现了一版简易的webpack,源码100+行,食用时伴着注释很容易消化,感兴趣的可前往看个思路。
  • 发布订阅的使用和实现,并且如何实现一个可先订阅后发布的机制?
上面也提到需要使用到发布订阅模式,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
  • 为什么使用JSONP而不用socke通信获取更新过的代码?
因为通过socket通信获取的是一串字符串需要再做处理。而通过JSONP获取的代码可以直接执行。

引用

招聘

急缺前端,对搜狐焦点感兴趣的直接简历发我邮件<ketingwang213821@sohu-inc.com>或加我vx: Careteen

查看原文

赞 49 收藏 30 评论 6

Shio 赞了回答 · 5月27日

解决hexo怎么在菜单上添加页面和分类呢?

你是指主题的_config.yml吧?
Hexo生成的Tag/Archive/Category页的目录名在根目录下的站点_config.yml里配置,配置项是tag_dir/archive_dir/category_dir。默认值是tags/archives/categories。
主题的_config.yml所配置的菜单只是设置“有叫XXX这个名字的菜单项,指向YYY这个URL”(XXX: YYY)。所以你需要配置你的Category菜单项指向站点配置里的category_dir才有效。

Hexo默认不生成About页面,有需要的话自己创建一个叫about的page然后再添加菜单项。

关注 5 回答 3

Shio 赞了问题 · 5月27日

解决hexo怎么在菜单上添加页面和分类呢?

我在 _config.yml 中设置的菜单有 Home,Archive,Category,About,可是只有HomeArchive点进去才有内容,CategoryAbout都错Can't found

还需要其他哪里设置才能生效么?

关注 5 回答 3

Shio 收藏了文章 · 2月19日

React之key详解

一个例子

有这样的一个场景如下图所示,有一组动态数量的input,可以增加和删除和重新排序,数组元素生成的组件用index作为key的值,例如下图生成的ui展示:

图片描述
上面例子中的input组件渲染的代码如下所示,全部完整代码可以参考 ==>完整code

{this.state.data.map((v,idx)=><Item key={idx} v={v} />)}

//Item组件render方法
render(){
   return <li>{this.props.v} <input type="text"/></li>
}

首先说明的是,若页面中数组内容是固定而不是动态的话,上面的代码也不会有什么问题(。•ˇ‸ˇ•。 但是如此这也是不是推荐的做法)。

但是,动态数组导致其渲染的组件就会有问题,从上面图中你也能看出问题:数组动态改变后,页面上input的输入内容跟对应的数组元素顺序不对应。

为什么会这样呢?本文后面会有解释。react初学者对这可能更加迷惑,本文就来跟大家探讨一下react的key用法,

react key概述

key的作用

react中的key属性,它是一个特殊的属性,它是出现不是给开发者用的(例如你为一个组件设置key之后不能获取组件的这个key props),而是给react自己用的。

那么react是怎么用key的呢?react的作者之一Paul O’Shannessy有提到:

Key is not really about performance, it’s more about identity (which in turn leads to better performance). Randomly assigned and changing values do not form an identity

简单来说,react利用key来识别组件,它是一种身份标识标识,就像我们的身份证用来辨识一个人一样。每个key对应一个组件,相同的key react认为是同一个组件,这样后续相同的key对应组件都不会被创建。例如下面代码:

//this.state.users内容
this.state = {
 users: [{id:1,name: '张三'}, {id:2, name: '李四'}, {id: 2, name: "王五"}],
 ....//省略
}
render()
 return(
  <div>
    <h3>用户列表</h3>
    {this.state.users.map(u => <div key={u.id}>{u.id}:{u.name}</div>)}
  </div>
 )
);

上面代码在dom渲染挂载后,用户列表只有张三李四两个用户,王五并没有展示处理,主要是因为react根据key认为李四王五是同一个组件,导致第一个被渲染,后续的会被丢弃掉。

这样,有了key属性后,就可以与组件建立了一种对应关系,react根据key来决定是销毁重新创建组件还是更新组件。

  • key相同,若组件属性有所变化,则react只更新组件对应的属性;没有变化则不更新。

  • key值不同,则react先销毁该组件(有状态组件的componentWillUnmount会执行),然后重新创建该组件(有状态组件的constructorcomponentWillUnmount都会执行)

另外需要指明的是:

key不是用来提升react的性能的,不过用好key对性能是有帮组的。

key的使用场景

在项目开发中,key属性的使用场景最多的还是由数组动态创建的子组件的情况,需要为每个子组件添加唯一的key属性值。

那么,为何由数组动态创建的组件必须要用到key属性呢?这跟数组元素的动态性有关。

拿上述用户列表的例子来说,看一下babel对上述代码的转换情况:

// 转换前
const element = (
  <div>
    <h3>用户列表</h3>
    {[<div key={1}>1:张三</div>, <div key={2}>2:李四</div>]}
  </div>
);

// 转换后
"use strict";

var element = React.createElement(
  "div",
  null,
  React.createElement("h3",null,"用户列表"),
  [
    React.createElement("div",{ key: 1 },"1:张三"), 
    React.createElement("div",{ key: 2 },"2:李四")
  ]
);

有babel转换后React.createElement中的代码可以看出,其它元素之所以不是必须需要key是因为不管组件的state或者props如何变化,这些元素始终占据着React.createElement固定的位置,这个位置就是天然的key。

而由数组创建的组件可能由于动态的操作导致重新渲染时,子组件的位置发生了变化,例如上面用户列表子组件新增一个用户,上面两个用户的位置可能变化为下面这样:

var element = React.createElement(
  "div",
  null,
  React.createElement("h3",null,"用户列表"),
  [
    React.createElement("div",{ key: 3 },"1:王五"), 
    React.createElement("div",{ key: 1 },"2:张三"), 
    React.createElement("div",{ key: 2 },"3:李四")
  ]
);

可以看出,数组创建子组件的位置并不固定,动态改变的;这样有了key属性后,react就可以根据key值来判断是否为同一组件。

另外,还有一种比较常见的场景:为一个有复杂繁琐逻辑的组件添加key后,后续操作可以改变该组件的key属性值,从而达到先销毁之前的组件,再重新创建该组件。

key的最佳实践

上面说到了,由数组创建的子组件必须有key属性,否则的话你可能见到下面这样的warning:

Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of `ServiceInfo`. See https://fb.me/react-warning-keys for more information.

可能你会发现,这只是warning而不是error,它不是强制性的,为什么react不强制要求用key而报error呢?其实是强制要求的,只不过react为按要求来默认上帮我们做了,它是以数组的index作为key的。

index作为key是一种反模式

在list数组中,用key来标识数组创建子组件时,若数组的内容只是作为纯展示,而不涉及到数组的动态变更,其实是可以使用index作为key的。

但是,若涉及到数组的动态变更,例如数组新增元素、删除元素或者重新排序等,这时index作为key会导致展示错误的数据。本文开始引入的例子就是最好的证明。

{this.state.data.map((v,idx)=><Item key={idx} v={v} />)}
// 开始时:['a','b','c']=>
<ul>
    <li key="0">a <input type="text"/></li>
    <li key="1">b <input type="text"/></li>
    <li key="2">c <input type="text"/></li>
</ul>

// 数组重排 -> ['c','b','a'] =>
<ul>
    <li key="0">c <input type="text"/></li>
    <li key="1">b <input type="text"/></li>
    <li key="2">a <input type="text"/></li>
</ul>

上面实例中在数组重新排序后,key对应的实例都没有销毁,而是重新更新。具体更新过程我们拿key=0的元素来说明, 数组重新排序后:

  • 组件重新render得到新的虚拟dom;

  • 新老两个虚拟dom进行diff,新老版的都有key=0的组件,react认为同一个组件,则只可能更新组件;

  • 然后比较其children,发现内容的文本内容不同(由a--->c),而input组件并没有变化,这时触发组件的componentWillReceiveProps方法,从而更新其子组件文本内容;

  • 因为组件的children中input组件没有变化,其又与父组件传入的任props没有关联,所以input组件不会更新(即其componentWillReceiveProps方法不会被执行),导致用户输入的值不会变化。

这就是index作为key存在的问题,所以不要使用index作为key

key的值要稳定唯一

在数组中生成的每项都要有key属性,并且key的值是一个永久且唯一的值,即稳定唯一。

在理想情况下,在循环一个对象数组时,数组的每一项都会有用于区分其他项的一个键值,相当数据库中主键。这样就可以用该属性值作为key值。但是一般情况下可能是没有这个属性值的,这时就需要我们自己保证。

但是,需要指出的一点是,我们在保证数组每项的唯一的标识时,还需要保证其值的稳定性,不能经常改变。例如下面代码:

{
    this.state.data.map(el=><MyComponent key={Math.random()}/>)
}

上面代码中中MyComponent的key值是用Math.random随机生成的,虽然能够保持其唯一性,但是它的值是随机而不是稳定的,在数组动态改变时会导致数组元素中的每项都重新销毁然后重新创建,有一定的性能开销;另外可能导致一些意想不到的问题出现。所以:

key的值要保持稳定且唯一,不能使用random来生成key的值。

所以,在不能使用random随机生成key时,我们可以像下面这样用一个全局的localCounter变量来添加稳定唯一的key值。

var localCounter = 1;
this.data.forEach(el=>{
    el.id = localCounter++;
});
//向数组中动态添加元素时,
function createUser(user) {
    return {
        ...user,
        id: localCounter++
    }
}

key其它注意事项

当然除了为数据元素生成的组件要添加key,且key要稳定且唯一之外,还需要注意以下几点:

  • key属性是添加到自定义的子组件上,而不是子组件内部的顶层的组件上。

//MyComponent
...
render() {//error
    <div key={{item.key}}>{{item.name}}</div>
}
...

//right
<MyComponent key={{item.key}}/>
  • key值的唯一是有范围的,即在数组生成的同级同类型的组件上要保持唯一,而不是所有组件的key都要保持唯一

  • 不仅仅在数组生成组件上,其他地方也可以使用key,主要是react利用key来区分组件的,相同的key表示同一个组件,react不会重新销毁创建组件实例,只可能更新;key不同,react会销毁已有的组件实例,重新创建组件新的实例

{
  this.state.type ? 
    <div><Son_1/><Son_2/></div>
    : <div><Son_2/><Son_1/></div>
}

例如上面代码中,this.state.type的值改变时,原Son_1和Son2组件的实例都将会被销毁,并重新创建Son_1和Son_2组件新的实例,不能继承原来的状态,其实他们只是互换了位置。为了避免这种问题,我们可以给组件加上key。

{
  this.state.type ? 
    <div><Son_1 key="1"/><Son_2 key="2"/></div>
    : <div><Son_2 key="2" /><Son_1 key="1"/></div>
}

这样,this.state.type的值改变时,Son_1和Son2组件的实例没有重新创建,react只是将他们互换位置。

参考文献

查看原文

Shio 收藏了问题 · 2月2日

关于 ant design mobile 表单验证的 getFieldProps问题

如何写出自定义验证规则?

 <List renderHeader={() => 'test'}>
                        <InputItem
                            {...getFieldProps('phone',{rules: [{required: true,message:"123"}],})}
                            type="phone"
                            placeholder="input your phone"
                            error={getFieldError('phone')?true:false}
                        >手机号码</InputItem>
    
                    </List>
                    

现在的规则是必填,当然也可以用其他的定义好的规则 比如 type:string子类的,
但是可以设置自定义的验证函数吗?

Shio 收藏了文章 · 2019-11-12

vue 里主动销毁 keep-alive 缓存的组件

问题产生的背景

我们一个后台,在切换一些标签页的时候,是使用的 keep-alive 缓存的标签页,也使用了 include 属性来决定哪个页面进行缓存,而标签页的切换实际上是路由的切换,也就是说打开一个新标签页的时候,url 会跟着变化,老的标签页如果在 keep-aliveinclude 范围内那就会缓存下来。
然后客服人员就反馈页面开的久了就会崩溃,因为他们基础上不会刷新页面(工作需要),又总有切换标签的习惯,最后导致内存越来越大最后崩溃。

依赖环境

这个项目是基于一个开源 vue 后台框架:https://github.com/PanJiaChen/vue-element-admin,然后代码一直由几个后端开发维护的!所以后端没找出问题在哪,然后就我来接手这个问题了。
写文章时,标签里竟然没有 vue 这一项,差评!

定位问题

先梳理下业务逻辑:从业务场景来说,我们在标签页之间切换时,如果刚进入的这个标签页已被缓存了,那被缓存的标签页就直接拿出来展示就行,而关闭这个标签页的时候就应该销毁对应的组件。

clipboard.png

花了点时间查看了一下代码,发现问题在于关闭标签页的时候,虽然这个页面没在 keep-aliveinclude 里了,但是组件也没有被销毁掉,还是在缓存状态,我们可以通过 Vue Devtools 插件看到关闭后的标签页对应的组件一直还存在着:

clipboard.png

当然,在这块 keep-alive 本身的逻辑我觉得是没问题的,主要是我们项目比较复杂,缓存的组件太多了而且会一直增加,所以最终导致崩溃。

解决问题

既然问题已经定位了,那就好解决问题了,只需要在关闭标签页的时候把对应的组件也销毁掉就好了。

经过网上一翻查找,发现销毁一个组件可以使用: this.$destroy(‘组件名’) 来销毁。

先说下大概思路:keep-aliveinclude 里存的其实是一个 vuex 中的一个数据源(数据源保存的是路由名称),当关闭标签页时,这个数据源中的一项会被移除。这之前,我们在组件里监听到这个数据源的变化,如果此组件对应的路由(这个路由应在 mounted 的时候保存下来)已经不在数据源中了,那就应该销毁此组件。

代码大概如下:

const mixin = {
  data() {
    return {
      routePath: ''
    }
  },
  mounted() {
    this.routePath = this.$route.path
  },
  computed: {
    visitedViews() {
      return this.$store.state.tagsView.visitedViews
    }
  },
  watch: {
    visitedViews(value) {
      if(value 里没有了 routePath 这一项)
        this.$destroy(this.$options.name)
      }
    }
  }
}

export default mixin

这一段代码单独拎出来了,然后在需要缓存的组件里使用 mixins 混入到组件对象中,这样组件中要添加的代码量就比较少了。

更改后经过测试,关闭标签页后对应的组件就会被销毁掉,使用 Vue Devtools 能看的很清楚。

更多思考

在我们后台操作这么频繁的业务场景下,使用 keep-alive 其实并不是一个好的选择。

在我们修复这个问题后,我们通过控制台里的 Memory 查看页面内存的变化时,发现组件即便被销毁,也要经过一段时间才能回收完,当我们在这一段时间一直创建/打开新的标签页时,内存还是会在短时间内高涨。而且有时候,内存在经过一段时间后也并没有回收掉。

keep-alive 本质上是把整个 dom 节点及对应的事件等都缓存下来了,当这样的组件很多的时候,自然会占用很多内存。而如果我们只缓存这个组件中的数据,在需要这个组件再次显示的时候再临时渲染那肯定要节省很多内存的,毕竟数据占的空间其实很小的,而渲染组件要花的时间也不会很长(只要组件不是特别特别复杂)。

所以,下一阶段的优化工作就是把 keep-alive 去掉,然后使用 vuex 来缓存组件中的数据,当需要重新显示数据时再把数据取出来并重新渲染。当然,这是一个比较大的工程!

查看原文

认证与成就

  • 获得 86 次点赞
  • 获得 14 枚徽章 获得 1 枚金徽章, 获得 2 枚银徽章, 获得 11 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-05-16
个人主页被 1k 人浏览