开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 3 天,点击查看活动详情
前言
笔者最近做项目时发现safari 浏览器下 Element UI 表格一个奇怪的bug(表现见封面),在海量密集搜索之后,居然没有查到类似结果,于是打算自己操刀,深入源码分析bug 的成因,本篇文章带你详细了解。
表现现象
这个 bug 只在 safari 浏览器下面出现,表格的宽度会不断增大,而在其他浏览器未见异常。
成因分析
既然我们知道是表格宽度不断增大导致,那接下来看看源码是什么导致宽度不断增大的。
根据宽度不断变大的类名,我们在 packages/table.vue
中,很快就发现了这个:
<table-body
fixed="left"
:store="store"
:stripe="stripe"
:highlight="highlightCurrentRow"
:row-class-name="rowClassName"
:row-style="rowStyle"
:style="{
width: bodyWidth
}">
</table-body>
所以可以很明确是由于 bodyWidth
导致的,那么bodyWidth
是怎么计算的呢?
bodyWidth() {
const { bodyWidth, scrollY, gutterWidth } = this.layout;
return bodyWidth ? bodyWidth - (scrollY ? gutterWidth : 0) + 'px' : '';
}
很明显,bodyWidth
是一个computed
,它依赖layout
的bodyWidth
,那么layout.bodyWidth
又是怎么计算的呢?在table-layout.js
文件中它给出了答案。
updateColumnsWidth() {
if (Vue.prototype.$isServer) return;
const fit = this.fit;
const bodyWidth = this.table.$el.clientWidth;
let bodyMinWidth = 0;
// 平铺所有的列
const flattenColumns = this.getFlattenColumns();
// 自动适应列
let flexColumns = flattenColumns.filter((column) => typeof column.width !== 'number');
// 重置真正的列宽
flattenColumns.forEach((column) => { // Clean those columns whose width changed from flex to unflex
if (typeof column.width === 'number' && column.realWidth) column.realWidth = null;
});
// 存在需要自适应的列
if (flexColumns.length > 0 && fit) {
// 所需最小的宽度
flattenColumns.forEach((column) => {
bodyMinWidth += column.width || column.minWidth || 80;
});
// 滚动条的宽度
const scrollYWidth = this.scrollY ? this.gutterWidth : 0;
// 如果所需最小宽度小于当前表格宽度
if (bodyMinWidth <= bodyWidth - scrollYWidth) { // DON'T HAVE SCROLL BAR
this.scrollX = false;
// 剩余可以自适应的宽度
const totalFlexWidth = bodyWidth - scrollYWidth - bodyMinWidth;
// 自适应的列只有一个,则用默认宽度加上剩余宽度
if (flexColumns.length === 1) {
flexColumns[0].realWidth = (flexColumns[0].minWidth || 80) + totalFlexWidth;
} else {
// 所有动态列所需的宽度
const allColumnsWidth = flexColumns.reduce((prev, column) => prev + (column.minWidth || 80), 0);
// 当前剩余的宽度 / 所需的剩余宽度
const flexWidthPerPixel = totalFlexWidth / allColumnsWidth;
// 除了第一个的剩余宽度
let noneFirstWidth = 0;
// 遍历所有需要动态调整的列
flexColumns.forEach((column, index) => {
if (index === 0) return;
// 每一列可以分配到的宽度
const flexWidth = Math.floor((column.minWidth || 80) * flexWidthPerPixel);
noneFirstWidth += flexWidth;
// 当前列宽度 = 当前最小宽度 + 分配的宽度
column.realWidth = (column.minWidth || 80) + flexWidth;
});
// 第一列的宽度,另行计算
// 第一列宽度 = 当前最小宽度 + 分配给其他列剩余后的宽度
flexColumns[0].realWidth = (flexColumns[0].minWidth || 80) + totalFlexWidth - noneFirstWidth;
}
} else { // HAVE HORIZONTAL SCROLL BAR
this.scrollX = true;
flexColumns.forEach(function(column) {
column.realWidth = column.minWidth;
});
}
// 重新赋值 bodyWidth
this.bodyWidth = Math.max(bodyMinWidth, bodyWidth);
this.table.resizeState.width = this.bodyWidth;
} else {
flattenColumns.forEach((column) => {
if (!column.width && !column.minWidth) {
column.realWidth = 80;
} else {
column.realWidth = column.width || column.minWidth;
}
bodyMinWidth += column.realWidth;
});
this.scrollX = bodyMinWidth > bodyWidth;
this.bodyWidth = bodyMinWidth;
}
const fixedColumns = this.store.states.fixedColumns;
if (fixedColumns.length > 0) {
let fixedWidth = 0;
fixedColumns.forEach(function(column) {
fixedWidth += column.realWidth || column.width;
});
this.fixedWidth = fixedWidth;
}
const rightFixedColumns = this.store.states.rightFixedColumns;
if (rightFixedColumns.length > 0) {
let rightFixedWidth = 0;
rightFixedColumns.forEach(function(column) {
rightFixedWidth += column.realWidth || column.width;
});
this.rightFixedWidth = rightFixedWidth;
}
this.notifyObservers('columns');
}
从上面我们可以看到,在updateColumnsWidth
方法中,它更新了bodyWidth
值。那么什么时候会调用updateColumnsWidth
呢?
doLayout() {
if (this.shouldUpdateHeight) {
this.layout.updateElsHeight();
}
this.layout.updateColumnsWidth();
}
在table.vue
文件中,有一个调用顺序值得我们关注:
resizeListener
-> doLayout
-> updateColumnsWidth
resizeListener() {
if (!this.$ready) return;
let shouldUpdateLayout = false;
const el = this.$el;
const { width: oldWidth, height: oldHeight } = this.resizeState;
// 表格的宽度
const width = el.offsetWidth;
if (oldWidth !== width) {
shouldUpdateLayout = true;
}
const height = el.offsetHeight;
if ((this.height || this.shouldUpdateHeight) && oldHeight !== height) {
shouldUpdateLayout = true;
}
if (shouldUpdateLayout) {
this.resizeState.width = width;
this.resizeState.height = height;
this.doLayout();
}
}
resizeListener
是一个表格大小变化的监听回调,如果表格大小发生改变,那么该回调被触发。在resizeListener
中,它会判断表格的宽度或者高度是否发生变化,变化则触发重新布局,更新每列的宽度。那么现在成因基本清晰了:resizeListener
-> doLayout
-> updateColumnsWidth
这条线造成了死循环。但是为什么 chrome
不会造成这个问题呢?我们看看下面截图:
safari(版本15.2 (17612.3.6.1.6)):
chrome(版本 107.0.5304.110(正式版本) (arm64)):
我们可以看到,在safari
浏览器中,table-layout:fixed
的子元素是654px
,它的父元素计算出width
为673px
,但是chrome
浏览器中,它的宽度都是654px
,这个差异暂时没有找到原因,有资料显示是safari
的bug,知道同学可以告知下🙏。
这样就可以解释了:每次获取el.offsetWidth
都比设置的bodyWidth
大,所以在updateColumnsWidth
更新bodyWdith
之后,触发表格大小变化,进而触发resizeListener
监听回调,但是获取的el.offsetWidth
大于 bodyWidth
,导致触发doLayout
重布局,而doLayout
中调用updateColumnsWidth
来更新每列宽度,最终造成了更新的死循环。
解决方案
解决方案就是阻断那个更新死循环,简单的方式可以这样:
1、固定表格的宽度
固定表格的宽度,这样就不会触发 resizeListener
回调了,更新死循环自然就不存在了。
::v-deep .el-table__body {
width: 100% !important;
}
2、修改源码
修改源码来打破死循环,但是项目一般不建议,除非使用的是二次封装的组件库。
源码调试
在我们项目中如何在线调试源码?
观察包的入口package.json
- main : 定义了 npm 包的入口文件,browser 环境和 node 环境均可使用
- module : 定义 npm 包的 ESM 规范的入口文件,browser 环境和 node 环境均可使用
- browser : 定义 npm 包在 browser 环境下的入口文件
- unpkg:定义了npm包在 cdn 环境下的入口文件
{
"_args": [
[
"element-ui@2.15.6",
"/Users/zego/棱镜项目/boss-web-prism"
]
],
"_from": "element-ui@2.15.6",
"_id": "element-ui@2.15.6",
"_inBundle": false,
"_integrity": "sha512-rcYXEKd/j2G0AgficAOk1Zd1AsnHRkhmrK4yLHmNOiimU2JfsywgfKUjMoFuT6pQx0luhovj8lFjpE4Fnt58Iw==",
"_location": "/element-ui",
"_phantomChildren": {},
"_requested": {
"type": "version",
"registry": true,
"raw": "element-ui@2.15.6",
"name": "element-ui",
"escapedName": "element-ui",
"rawSpec": "2.15.6",
"saveSpec": null,
"fetchSpec": "2.15.6"
},
"_requiredBy": [
"/"
],
"_resolved": "https://registry.npmjs.org/element-ui/-/element-ui-2.15.6.tgz",
"_spec": "2.15.6",
"_where": "/Users/zego/棱镜项目/boss-web-prism",
"bugs": {
"url": "https://github.com/ElemeFE/element/issues"
},
"dependencies": {
"async-validator": "~1.8.1",
"babel-helper-vue-jsx-merge-props": "^2.0.0",
"deepmerge": "^1.2.0",
"normalize-wheel": "^1.0.1",
"resize-observer-polyfill": "^1.5.0",
"throttle-debounce": "^1.0.1"
},
"description": "A Component Library for Vue.js.",
"devDependencies": {
"@vue/component-compiler-utils": "^2.6.0",
"algoliasearch": "^3.24.5",
"babel-cli": "^6.26.0",
"babel-core": "^6.26.3",
"babel-loader": "^7.1.5",
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-istanbul": "^4.1.1",
"babel-plugin-module-resolver": "^2.2.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.7.0",
"babel-preset-env": "^1.7.0",
"babel-preset-stage-2": "^6.24.1",
"babel-regenerator-runtime": "^6.5.0",
"chai": "^4.2.0",
"chokidar": "^1.7.0",
"copy-webpack-plugin": "^5.0.0",
"coveralls": "^3.0.3",
"cp-cli": "^1.0.2",
"cross-env": "^3.1.3",
"css-loader": "^2.1.0",
"es6-promise": "^4.0.5",
"eslint": "4.18.2",
"eslint-config-elemefe": "0.1.1",
"eslint-loader": "^2.0.0",
"eslint-plugin-html": "^4.0.1",
"eslint-plugin-json": "^1.2.0",
"file-loader": "^1.1.11",
"file-save": "^0.2.0",
"gulp": "^4.0.0",
"gulp-autoprefixer": "^6.0.0",
"gulp-cssmin": "^0.2.0",
"gulp-sass": "^4.0.2",
"highlight.js": "^9.3.0",
"html-webpack-plugin": "^3.2.0",
"json-loader": "^0.5.7",
"json-templater": "^1.0.4",
"karma": "^4.0.1",
"karma-chrome-launcher": "^2.2.0",
"karma-coverage": "^1.1.2",
"karma-mocha": "^1.3.0",
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "^0.0.32",
"karma-webpack": "^3.0.5",
"markdown-it": "^8.4.1",
"markdown-it-anchor": "^5.0.2",
"markdown-it-chain": "^1.3.0",
"markdown-it-container": "^2.0.0",
"mini-css-extract-plugin": "^0.4.1",
"mocha": "^6.0.2",
"node-sass": "^4.11.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"postcss": "^7.0.14",
"progress-bar-webpack-plugin": "^1.11.0",
"rimraf": "^2.5.4",
"sass-loader": "^7.1.0",
"select-version-cli": "^0.0.2",
"sinon": "^7.2.7",
"sinon-chai": "^3.3.0",
"style-loader": "^0.23.1",
"transliteration": "^1.1.11",
"uglifyjs-webpack-plugin": "^2.1.1",
"uppercamelcase": "^1.1.0",
"url-loader": "^1.0.1",
"vue": "2.5.21",
"vue-loader": "^15.7.0",
"vue-router": "^3.0.1",
"vue-template-compiler": "2.5.21",
"vue-template-es2015-compiler": "^1.6.0",
"webpack": "^4.14.0",
"webpack-cli": "^3.0.8",
"webpack-dev-server": "^3.1.11",
"webpack-node-externals": "^1.7.2"
},
"faas": [
{
"domain": "element",
"public": "temp_web/element"
},
{
"domain": "element-theme",
"public": "examples/element-ui",
"build": [
"yarn",
"npm run deploy:build"
]
}
],
"files": [
"lib",
"src",
"packages",
"types"
],
"homepage": "http://element.eleme.io",
"keywords": [
"eleme",
"vue",
"components"
],
"license": "MIT",
"main": "lib/element-ui.common.js",
"name": "element-ui",
"peerDependencies": {
"vue": "^2.5.17"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/ElemeFE/element.git"
},
"scripts": {
"bootstrap": "yarn || npm i",
"build:file": "node build/bin/iconInit.js & node build/bin/build-entry.js & node build/bin/i18n.js & node build/bin/version.js",
"build:theme": "node build/bin/gen-cssfile && gulp build --gulpfile packages/theme-chalk/gulpfile.js && cp-cli packages/theme-chalk/lib lib/theme-chalk",
"build:umd": "node build/bin/build-locale.js",
"build:utils": "cross-env BABEL_ENV=utils babel src --out-dir lib --ignore src/index.js",
"clean": "rimraf lib && rimraf packages/*/lib && rimraf test/**/coverage",
"deploy:build": "npm run build:file && cross-env NODE_ENV=production webpack --config build/webpack.demo.js && echo element.eleme.io>>examples/element-ui/CNAME",
"deploy:extension": "cross-env NODE_ENV=production webpack --config build/webpack.extension.js",
"dev": "npm run bootstrap && npm run build:file && cross-env NODE_ENV=development webpack-dev-server --config build/webpack.demo.js & node build/bin/template.js",
"dev:extension": "rimraf examples/extension/dist && cross-env NODE_ENV=development webpack --watch --config build/webpack.extension.js",
"dev:play": "npm run build:file && cross-env NODE_ENV=development PLAY_ENV=true webpack-dev-server --config build/webpack.demo.js",
"dist": "npm run clean && npm run build:file && npm run lint && webpack --config build/webpack.conf.js && webpack --config build/webpack.common.js && webpack --config build/webpack.component.js && npm run build:utils && npm run build:umd && npm run build:theme",
"i18n": "node build/bin/i18n.js",
"lint": "eslint src/**/* test/**/* packages/**/* build/**/* --quiet",
"pub": "npm run bootstrap && sh build/git-release.sh && sh build/release.sh && node build/bin/gen-indices.js",
"test": "npm run lint && npm run build:theme && cross-env CI_ENV=/dev/ BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
"test:watch": "npm run build:theme && cross-env BABEL_ENV=test karma start test/unit/karma.conf.js"
},
"style": "lib/theme-chalk/index.css",
"typings": "types/index.d.ts",
"unpkg": "lib/index.js",
"version": "2.15.6"
}
比如 Element UI 的入口字段main
是lib/element-ui.common.js
,我们打开这个文件,则可以看到所有代码打包到这个里面,根据源码相应的方法名,则可以检索到对应的方法,然后直接 console.log
就可以进行调试代码了。
总结
本文通过Element UI
的一个bug,通过其表现现象、然后深入源码分析定位问题,并给出了相应的解决方案,最后讲解了如何进行源码调试。
暂无评论内容