这事得从 Element 的一个 bug 说起…

开启掘金成长之旅!这是我参与「掘金日新计划 · 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,它依赖layoutbodyWidth,那么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,它的父元素计算出width673px,但是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 的入口字段mainlib/element-ui.common.js,我们打开这个文件,则可以看到所有代码打包到这个里面,根据源码相应的方法名,则可以检索到对应的方法,然后直接 console.log就可以进行调试代码了。

总结

本文通过Element UI的一个bug,通过其表现现象、然后深入源码分析定位问题,并给出了相应的解决方案,最后讲解了如何进行源码调试。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容