一个只分享有价值技术干货的微信公众号
美团技术团队会定期推送来自一线的实践技术文章,涵盖前端(Web、iOS和Android)、后台、大数据、AI/算法、测试、运维等技术领域。 在这里,与8000多业界一流工程师交流切磋。
Shio 关注了用户 · 2020-07-28
一个只分享有价值技术干货的微信公众号
美团技术团队会定期推送来自一线的实践技术文章,涵盖前端(Web、iOS和Android)、后台、大数据、AI/算法、测试、运维等技术领域。 在这里,与8000多业界一流工程师交流切磋。
关注 4975
Shio 收藏了文章 · 2020-07-24
抽象语法树(AST),是一个非常基础而重要的知识点,但国内的文档却几乎一片空白。本文将带大家从底层了解AST,并且通过发布一个小型前端工具,来带大家了解AST的强大功能
Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。
我们对javascript生态了如指掌,却常忽视javascript本身。这台机器,究竟是哪些零部件在支持着它运行?
AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。
事实上,在javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。
小时候,当我们拿到一个螺丝刀和一台机器,人生中最令人怀念的梦幻时刻便开始了:
我们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一起...
当我们把它重新照不同的方式组装起来,这时,机器重新又跑动了起来——世界在你眼中如获新生。
通过抽象语法树解析,我们可以像童年时拆解玩具一样,透视Javascript这台机器的运转,并且重新按着你的意愿来组装。
现在,我们拆解一个简单的add函数
function add(a, b) {
return a + b
}
首先,我们拿到的这个语法块,是一个FunctionDeclaration(函数定义)对象。
用力拆开,它成了三块:
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,它成了三部分,left
,operator
,right
operator
即+
left
里面装的,是Identifier对象 a
right
里面装的,是Identifer对象 b
就这样,我们把一个简单的add函数拆解完毕,用图表示就是
看!抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。
那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?
请查看 AST对象文档
输入命令:
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里面提供了不少“模具”,让你可以轻松地拼接成新的机器。
最简单的例子,我们想把之前的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代码”的幻觉?我郑重告诉你,这不是幻觉。
除了parse/print/builder以外,Recast的三项主要功能:
我们通过一个系列小务来学习全部的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
}
新建一个名为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的内容转换回源码,以方便调试。
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对象内的节点进行逐个遍历。
注意
#!/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,即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`进行测试。
exportific.js
现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如
function add (a, b) {
return a + b
}
想改变为
exports.add = (a, b) => {
return a + b
}
除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题。
查询AST对象文档
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)
})
上面讲了那么多,仍然只体现在理论阶段。
但通过简单的改写,就能通过recast制作成一个名为exportific的源码编辑工具。
以下代码添加作了两个小改动
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
文件了。
编辑一下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 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...
Shio 收藏了文章 · 2020-07-24
最近由于一篇分享手淘过年项目中采用到的前端技术的影响,重新研究了一下项目中CSS的架构
.本来打算写一篇文章,但是写到一半突然发现自己像在写文档介绍一样,所以后来就放弃了。但是觉得过程中研究的 Webpack
倒是可以单独拿出来讲一讲
在这里非常感谢印记中文 团队翻译的 Webpack 文档.
// 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
在进入按需加载的讲解之前,我们需要看一个问题 require
和 import
在 webpack
的执行过程是怎样的呢 ?现在我们在 src
建立两个文件 index.js
、module-es6.js
和 module-commonjs.js
。我们通过这三个文件解析 require
和 import
的执行过程
首先我们要区分的是 CommonJS
和 ES6
模块导出之间的区别,在 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
则会产生两个变量 A
和 B
. 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";
}
};
看完上面的 require
和 import
,我们回到 按需加载
这个执行过程. 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
,reject
和 promise
本身 存入一个数组,然后缓存起来.接着生成一个 script
标签,填充完信息之后添加到HTML
文件上,其中的 script
的 src
属性 就是我们按需加载的文件(module2
),webpack
会对这个 script
标签监听 error
和 load
时间,从而做相应的处理。
webpack
打包过程中会给 module2
添加一些代码,主要就是主动触发 window["webpackJsonp"].push
这个函数,这个函数会传递
两个参数 文件ID
和 文件内容对象
,其中 文件标示
如果没有配置的话,会按载入序号自动增长,文件内容对象
实际上就是上文说的 require.ensure
第一个参数dependencies
的文件内容,或者是 callback
,errorCallback
里面需要加载的文件,以 key(文件路径) --- value(文件内容)
的形式出现.里面执行的事情其实就是执行上面创建的promise
的resolve
函数,让require.ensure
里面的callback
执行,之后的执行情况就跟我上面将 requir
和 import
一样了
当然其实讲了那么长的 require.ensure
并没有什么用,因为这个函数已经被 import()
取代了,但是考虑到之前的版本应该有很多人都是用 require.ensure
方法去加载的,所以还是讲一下,而且其实 import
的执行过程跟 require.ensure
是一样的,只不过用了更友好的语法而已,所以关于 import
的执行流程我也没啥好讲的了,感兴趣的人看一下两者的 API
介绍就好了。
到这里就正式讲完了,如果有对此深入的同学路过看到有不对的地方,希望能帮我指出来.非常谢谢!!!
然后再次感谢印记中文 团队翻译的 Webpack 文档
查看原文Shio 收藏了文章 · 2020-07-24
HMR
即Hot Module Replacement
是指当你对代码修改并保存后,webpack
将会对代码进行重新打包,并将改动的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,去实现局部更新页面而非整体刷新页面。接下来将从使用到实现一版简易功能带领大家深入浅出HMR
。
文章首发于@careteen/webpack-hmr,转载请注明来源即可。
如上图所示,一个注册页面包含用户名
、密码
、邮箱
三个必填输入框,以及一个提交
按钮,当你在调试邮箱
模块改动了代码时,没做任何处理情况下是会刷新整个页面,频繁的改动代码会浪费你大量时间去重新填写内容。预期是保留用户名
、密码
的输入内容,而只替换邮箱
这一模块。这一诉求就需要借助webpack-dev-server
的热模块更新功能。
相对于live reload
整体刷新页面的方案,HMR
的优点在于可以保存应用的状态,提高开发效率。
首先借助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
目录中的文件
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
执行文件的内容字符。
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的产出文件混个眼熟,不要惧怕。其实任何一个不管多复杂的事物都是由更小更简单的东西组成,剖开它认识它爱上它。
接下来配置并感受一下热更新带来的便捷开发
webpack.config.js
配置
// ...
devServer: {
hot: true
}
// ...
./src/index.js
配置
// ...
if (module.hot) {
module.hot.accept(['./content.js'], () => {
render()
})
}
当更改./content.js
的内容并保存时,可以看到页面没有刷新,但是内容已经被替换了。
这对提高开发效率意义重大。接下来将一层层剖开它,认识它的实现原理。
如上图所示,右侧Server
端使用webpack-dev-server
去启动本地服务,内部实现主要使用了webpack
、express
、websocket
。
express
启动本地服务,当浏览器访问资源时对此做响应。websocket
实现长连接webpack
监听源文件的变化,即当开发者保存文件时触发webpack
的重新编译。
hash值
、已改动模块的json文件
、已改动模块代码的js文件
socket
向客户端推送当前编译的hash戳
客户端的websocket
监听到有文件改动推送过来的hash戳
,会和上一次对比
ajax
和jsonp
向服务端获取最新资源内存文件系统
去替换有修改的内容实现局部刷新上图先只看个大概,下面将从服务端和客户端两个方面进行详细分析
现在也只需要关注上图的右侧服务端部分,左侧可以暂时忽略。下面步骤主要是debug服务端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。
webpack-dev-server
服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173添加webpack的done事件回调,源代码地址@webpack-dev-server/Server.js#L122
添加webpack-dev-middleware中间件,源代码地址@webpack-dev-server/Server.js#L125
使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745
上面是我通过debug得出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配置文件
const compiler = webpack(config)
compiler代表整个webpack编译任务,全局只有一个
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来当启动服务的
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实例
。
let app = new express()
let fs = new MemoryFileSystem()
使用MemoryFileSystem
将compiler
的产出文件打包到内存中。
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启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件。
compiler.watch({}, err => {
console.log('又一次编译任务成功完成了')
})
以监控的模式启动一次webpack编译,当编译成功之后执行回调
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
// ...
}
listen(port) {
this.server.listen(port, () => {
console.log(`服务器已经在${port}端口上启动了`)
})
}
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重新编译时,向客户端推送hash
和ok
两个事件
感兴趣的可以根据上面debug服务端源码所带的源码位置,并在浏览器的调试模式下设置断点查看每个阶段的值。
node dev-server.js
使用我们自己编译的dev-server.js
启动服务,可看到页面可以正常展示,但还没有实现热更新。
下面将调式客户端的源代码分析其实现流程。
现在也只需要关注上图的左侧客户端部分,右侧可以暂时忽略。下面步骤主要是debug客户端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。
debug客户端源码分析其详细思路
上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合成一个文件。
在开发客户端功能之前,需要在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
进行缓存
socket.on('ok', () => {
reloadApp(true)
})
// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
if (hot) { // 如果hot为true 走热更新的逻辑
hotEmitter.emit('webpackHotUpdate')
} else { // 如果不支持热更新,则直接重新加载
window.location.reload()
}
}
在reloadApp中会进行判断,是否支持热更新,如果支持的话发射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。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
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的代码。
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()
})
}
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
获取的代码可以直接执行。
当客户端把最新的代码拉到浏览之后
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
}
}
实现我们可以在业务代码中定义需要热更新的模块以及回调函数,将其存放在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,可更改代码保存的同时查看浏览器并非整体刷新,而是局部更新代码进而更新视图。在涉及到大量表单的需求时大大提高了开发效率。
感兴趣的可前往debug CommonJs规范了解其实现原理。
webpack主要借助了tapable
这个库所提供的一系列同步/异步钩子函数贯穿整个生命周期。基于此我实现了一版简易的webpack,源码100+行,食用时伴着注释很容易消化,感兴趣的可前往看个思路。
上面也提到需要使用到发布订阅模式,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
因为通过socket通信获取的是一串字符串需要再做处理。而通过JSONP
获取的代码可以直接执行。
急缺前端,对搜狐焦点感兴趣的直接简历发我邮件<ketingwang213821@sohu-inc.com>或加我vx: Careteen
查看原文Shio 赞了文章 · 2020-07-24
HMR
即Hot Module Replacement
是指当你对代码修改并保存后,webpack
将会对代码进行重新打包,并将改动的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,去实现局部更新页面而非整体刷新页面。接下来将从使用到实现一版简易功能带领大家深入浅出HMR
。
文章首发于@careteen/webpack-hmr,转载请注明来源即可。
如上图所示,一个注册页面包含用户名
、密码
、邮箱
三个必填输入框,以及一个提交
按钮,当你在调试邮箱
模块改动了代码时,没做任何处理情况下是会刷新整个页面,频繁的改动代码会浪费你大量时间去重新填写内容。预期是保留用户名
、密码
的输入内容,而只替换邮箱
这一模块。这一诉求就需要借助webpack-dev-server
的热模块更新功能。
相对于live reload
整体刷新页面的方案,HMR
的优点在于可以保存应用的状态,提高开发效率。
首先借助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
目录中的文件
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
执行文件的内容字符。
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的产出文件混个眼熟,不要惧怕。其实任何一个不管多复杂的事物都是由更小更简单的东西组成,剖开它认识它爱上它。
接下来配置并感受一下热更新带来的便捷开发
webpack.config.js
配置
// ...
devServer: {
hot: true
}
// ...
./src/index.js
配置
// ...
if (module.hot) {
module.hot.accept(['./content.js'], () => {
render()
})
}
当更改./content.js
的内容并保存时,可以看到页面没有刷新,但是内容已经被替换了。
这对提高开发效率意义重大。接下来将一层层剖开它,认识它的实现原理。
如上图所示,右侧Server
端使用webpack-dev-server
去启动本地服务,内部实现主要使用了webpack
、express
、websocket
。
express
启动本地服务,当浏览器访问资源时对此做响应。websocket
实现长连接webpack
监听源文件的变化,即当开发者保存文件时触发webpack
的重新编译。
hash值
、已改动模块的json文件
、已改动模块代码的js文件
socket
向客户端推送当前编译的hash戳
客户端的websocket
监听到有文件改动推送过来的hash戳
,会和上一次对比
ajax
和jsonp
向服务端获取最新资源内存文件系统
去替换有修改的内容实现局部刷新上图先只看个大概,下面将从服务端和客户端两个方面进行详细分析
现在也只需要关注上图的右侧服务端部分,左侧可以暂时忽略。下面步骤主要是debug服务端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。
webpack-dev-server
服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173添加webpack的done事件回调,源代码地址@webpack-dev-server/Server.js#L122
添加webpack-dev-middleware中间件,源代码地址@webpack-dev-server/Server.js#L125
使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745
上面是我通过debug得出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配置文件
const compiler = webpack(config)
compiler代表整个webpack编译任务,全局只有一个
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来当启动服务的
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实例
。
let app = new express()
let fs = new MemoryFileSystem()
使用MemoryFileSystem
将compiler
的产出文件打包到内存中。
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启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件。
compiler.watch({}, err => {
console.log('又一次编译任务成功完成了')
})
以监控的模式启动一次webpack编译,当编译成功之后执行回调
constructor(compiler) {
// ...
this.server = require('http').createServer(app)
// ...
}
listen(port) {
this.server.listen(port, () => {
console.log(`服务器已经在${port}端口上启动了`)
})
}
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重新编译时,向客户端推送hash
和ok
两个事件
感兴趣的可以根据上面debug服务端源码所带的源码位置,并在浏览器的调试模式下设置断点查看每个阶段的值。
node dev-server.js
使用我们自己编译的dev-server.js
启动服务,可看到页面可以正常展示,但还没有实现热更新。
下面将调式客户端的源代码分析其实现流程。
现在也只需要关注上图的左侧客户端部分,右侧可以暂时忽略。下面步骤主要是debug客户端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。
debug客户端源码分析其详细思路
上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合成一个文件。
在开发客户端功能之前,需要在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
进行缓存
socket.on('ok', () => {
reloadApp(true)
})
// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
if (hot) { // 如果hot为true 走热更新的逻辑
hotEmitter.emit('webpackHotUpdate')
} else { // 如果不支持热更新,则直接重新加载
window.location.reload()
}
}
在reloadApp中会进行判断,是否支持热更新,如果支持的话发射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。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
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的代码。
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()
})
}
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
获取的代码可以直接执行。
当客户端把最新的代码拉到浏览之后
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
}
}
实现我们可以在业务代码中定义需要热更新的模块以及回调函数,将其存放在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,可更改代码保存的同时查看浏览器并非整体刷新,而是局部更新代码进而更新视图。在涉及到大量表单的需求时大大提高了开发效率。
感兴趣的可前往debug CommonJs规范了解其实现原理。
webpack主要借助了tapable
这个库所提供的一系列同步/异步钩子函数贯穿整个生命周期。基于此我实现了一版简易的webpack,源码100+行,食用时伴着注释很容易消化,感兴趣的可前往看个思路。
上面也提到需要使用到发布订阅模式,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
因为通过socket通信获取的是一串字符串需要再做处理。而通过JSONP
获取的代码可以直接执行。
急缺前端,对搜狐焦点感兴趣的直接简历发我邮件<ketingwang213821@sohu-inc.com>或加我vx: Careteen
查看原文赞 58 收藏 32 评论 6
Shio 赞了回答 · 2020-05-27
你是指主题的_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 赞了问题 · 2020-05-27
我在 _config.yml
中设置的菜单有 Home,Archive,Category,About
,可是只有Home
和Archive
点进去才有内容,Category
和About
都错Can't found
还需要其他哪里设置才能生效么?
关注 5 回答 3
Shio 收藏了文章 · 2020-02-19
有这样的一个场景如下图所示,有一组动态数量的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之后不能获取组件的这个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会执行
),然后重新创建该组件(有状态组件的constructor
和componentWillUnmount
都会执行)
另外需要指明的是:
key不是用来提升react的性能的,不过用好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属性,否则的话你可能见到下面这样的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的。
在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值。但是一般情况下可能是没有这个属性值的,这时就需要我们自己保证。
但是,需要指出的一点是,我们在保证数组每项的唯一的标识时,还需要保证其值的稳定性,不能经常改变。例如下面代码:
{
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属性是添加到自定义的子组件上,而不是子组件内部的顶层的组件上。
//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 收藏了问题 · 2020-02-02
如何写出自定义验证规则?
<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
我们一个后台,在切换一些标签页的时候,是使用的 keep-alive
缓存的标签页,也使用了 include
属性来决定哪个页面进行缓存,而标签页的切换实际上是路由的切换,也就是说打开一个新标签页的时候,url 会跟着变化,老的标签页如果在 keep-alive
的 include
范围内那就会缓存下来。
然后客服人员就反馈页面开的久了就会崩溃,因为他们基础上不会刷新页面(工作需要),又总有切换标签的习惯,最后导致内存越来越大最后崩溃。
这个项目是基于一个开源 vue
后台框架:https://github.com/PanJiaChen/vue-element-admin
,然后代码一直由几个后端开发维护的!所以后端没找出问题在哪,然后就我来接手这个问题了。
写文章时,标签里竟然没有 vue
这一项,差评!
先梳理下业务逻辑:从业务场景来说,我们在标签页之间切换时,如果刚进入的这个标签页已被缓存了,那被缓存的标签页就直接拿出来展示就行,而关闭这个标签页的时候就应该销毁对应的组件。
花了点时间查看了一下代码,发现问题在于关闭标签页的时候,虽然这个页面没在 keep-alive
的 include
里了,但是组件也没有被销毁掉,还是在缓存状态,我们可以通过 Vue Devtools
插件看到关闭后的标签页对应的组件一直还存在着:
当然,在这块 keep-alive
本身的逻辑我觉得是没问题的,主要是我们项目比较复杂,缓存的组件太多了而且会一直增加,所以最终导致崩溃。
既然问题已经定位了,那就好解决问题了,只需要在关闭标签页的时候把对应的组件也销毁掉就好了。
经过网上一翻查找,发现销毁一个组件可以使用: this.$destroy(‘组件名’)
来销毁。
先说下大概思路:keep-alive
的 include
里存的其实是一个 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
来缓存组件中的数据,当需要重新显示数据时再把数据取出来并重新渲染。当然,这是一个比较大的工程!
Shio 回答了问题 · 2019-09-06
应该是这两个事件冲突了,mouseenter
和 mouseleave
都是同一个元素上触发的事件,寻找 drawer
位置时就错乱了。暂时没找到比较理想的方法,有时间的话可以给框架提个 issue
等待解决,目前就只能使用 click
事件,或者 mouseenter
移入展开 + drawer
的 before-close
点击事件关闭。
https://codepen.io/jylzs369/p...
关注 1 回答 1
Shio 关注了问题 · 2019-09-06
以下代码是使用vue-cli初始化vue项目时,在app.vue
中添加的内容。
<template>
<div id="app">
<img data-original="./assets/logo.png" />
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
prop: 'haha'
}
},
mounted() {
this.test()
},
methods: {
test() {
let arr = [1]
arr.forEach(elem => {
console.log('this', this)
debugger
})
}
}
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
我在test()
方法中使用debugger
想获取this
的内容,通过console.log
可以打印出我预料到的内容(即VueComponent
对象信息)。但是通过在chrome中打断点,或者debugger的方式,this
的值却是undefinded
。
请问出现这种情况的原因是什么?
附上调试图
很感谢有人关注这些问题,一些评论提到“是不是还没跑到那一步你就查看this
的数据了”;
实际情况是,我的debugger
写在console.log('this', this)
下,且当程序执行到debugger
时,console.log
已经打印出了this
的信息,但是将鼠标放上this
上时,还是显示undefinded
关注 4 回答 4
Shio 回答了问题 · 2019-09-04
很有可能是打包之后样式合并在一起背景图被覆盖了。
关注 3 回答 2
Shio 回答了问题 · 2019-09-03
确定高德地图的文件放在你的执行代码之前引入了么?
关注 3 回答 2
Shio 回答了问题 · 2019-09-02
首先清空变为 null
是组件的行为没法控制。但 clear
行为会触发 change
事件,所以在 change
事件里去检测变更后的值再重置为有效的空值就好了:
<el-date-picler @change="change" />
change (value) {
if (value == null) value = '' // value = []
}
关注 3 回答 3
Shio 回答了问题 · 2019-08-30
background-position: top right
把背景定位为右上角,图片就可以从右至左的伸缩。可以参照CSS background-position 属性,取值有很多可能。
关注 6 回答 5
Shio 回答了问题 · 2019-08-29
可参照:
// views:加上ref标签和select事件
<el-table ref="table" @select="handleSelect">
...
</el-table>
// script
handleSelect (value, row) {
if (row.children && row.children.length) {
row.children.forEach(item => {
this.$refs.table.toggleRowSelection(item)
})
}
}
关注 3 回答 2
Shio 回答了问题 · 2019-08-28
使用 git cherry-pick
,这种方式用于在本地分支中将某一分支上的 commit
复制到其他分支。在想要添加 commit
的分支上,如此例的 B
分支中:
git cherry-pick D...F
...
表示一个 commit
范围选择,也可输入单个的 commit
。 这个命令可以挑选任意位置的 commit
合并到分支最新的位置,如果怕代码冲突时解决冲突造成 commit
提交信息改变可以加上 -x
保留原来的提交信息。
关注 2 回答 2
Shio 回答了问题 · 2019-08-26
因为 .ivu-table-cell
样式上有一个 overflow: hidden
关注 2 回答 1
Shio 回答了问题 · 2019-08-24
vm.$slots
是获取静态插槽内容的属性,直接得到每个插槽相应的 Vnode
节点,你的第一种写法 v-slot:KzName
或 #KzName
都是用作静态插槽。静态插槽的实现是在组件初始化刚开始的时候就去拿渲染后的内容,它定义在 initRender
方法中,并且这个方法的初次调用先于 created
,所以一开始可以在 created
里得到有值的内容。
作用域插槽的获取一定要使用 vm.$scopedSlots
,你后面的写法就是作用域插槽。并且在获取它的时候要在 mounted
里才能拿到,这是由于作用域插槽在组件初始化中要等到其他变量初始完才能正确获取变量的值,它的第一次赋值要等到组件渲染时才完成。虽然 $scopedSlots
的初始化也是在 initRender
方法里,但那时很明显只是给了它一个空对象,它的赋值是在 _render
方法中,所以你在渲染函数中是可以拿到的。
关于这两个属性的具体实现参考一下源码中的render.js
关注 4 回答 2
推荐关注