从零搭建前端工程(上)

文章内容太多,分为上下两部分,这里是上半部分。下半部分在《从零搭建前端工程(下)》,此篇的内容有:
1. 使用 webpack 打包(编译)vue
2. 使用 babel 处理 js
3. 使用 webpack-dev-sever 做热调试开发



产生这个的想法来自于某天接手一个项目,eslint 没配好,想着弄好格式化一下,一时之前忘了 cli 命令。
想一下,现在我们用的各种框架,都自带了脚手架工具,初始化后基本上不用怎么改造就可以上手开发。导致很多工程化的东西被人忽视了,就容易遇到问题时被卡住或者东找西找翻资料。
所以就想着自己从头搭建一个项目看看,遇到的一些配置、工具、命令都给记录下来,方便后面再遇到时候就不会生手了。

这里我就用 vue 来做基础框架,用 react 也一样,就一些编译工具的差别,大部分是一样的。要做的事有:

  1. 使用 webpack 打包(编译)vue
  2. 使用 babel 处理 js
  3. 使用 webpack-dev-sever 做热调试开发
  4. 使用 eslint + prettier 让代码健壮和优雅
  5. 使用 husky + lint-staged 强制增量各类检查
  6. 使用 @commitlint/cli 规范提交信息
  7. 使用 埋点(性能 + 错误) 让项目运行更好

零、文件初始化

准备一个和 vue 脚手架创建出来的初始工程一样。有:

src:项目源代码
src/main.js:工程入口文件,在这里做 vue 初始化,也是 webpack 的entry
src/App.vue:一个主页面
src/components/HelloWorld.vue:一个子组件
src/assets/logo.png:一张图片资源
src/public:公共资源文件夹,这里放的东西依样拷贝进打包里(除了index.html)
src/public/index.html:用来做单页面工程入口 html 的基础模板

然后初始化一下 npm 环境:

npm init -y

-y 就是创建一份默认的,不需要一步步问要填什么。直接生成空信息,有需要自己再去填就好了。

Read More

懒加载-虚拟列表-下拉菜单选择器

最近,遇到 select 表单下拉选项过多的问题。由于业务场景需要,选项是由接口请求回来,数量取决于线上数据的多少。

借此,顺便记录下目前遇到 select 组件有哪些优化选择。附上一些例子,使用 vue 写的。当然用 react 也是类似的做法,这个和用什么框架关系不大。

为了文章内容不要太长,例子里的共同样式放在了最后。

一、懒加载

懒加载这个词各位应该也很熟悉。在一个古老的例子,瀑布流显示图片就有这个词语了。就是第一屏只显示几条,用户有兴趣往下拉,再继续加载(或请求)新的数据。
以前的话应该是监听 scroll 事件,判断是否要见底了,来补充新数据。后面出了一个厉害的 IntersectionObserver 方法。具体怎么用就不赘述了,总之就是能监听一个 dom 的显隐,来触发回调。

例子可看代码,支持前端自己分批数据或者每次下拉由后端处理分批

<template>
  <div ref="lazy-load-select" class="lazy-load-select" :class="{ active: active }">
    <a class="display-label" href="javascript:;" @click="active = !active">{{
      displayLabel
    }}</a>
    <div class="list" v-show="active">
      <input
        type="text"
        class="select-input"
        @input="search"
        v-model="searchText"
      />
      <ul class="select-ul" ref="select-ul">
        <li
          v-for="item in displayData"
          ref="select-li"
          :class="{ 'select-li': true, selected: item.value === currentValue }"
          :key="item.value"
          @click="select(item.value)"
        >
          {{ item.label }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
function genData({ pageno, count, keyword }) {
  return new Array(count).fill('1').map((_, index) => {
    const value = (pageno - 1) * count + index + 1 + '';
    return {
      value,
      label: 'LazyLoad-' + value + (keyword || ''),
    };
  });
}

const originData = genData({ pageno: 1, count: 100 });

// 调试用的声明,true:使用全部数据返回,由前端分批,false:分批从后端获取数据
const defaultProps = false ? {
  originData: () => originData,
  getData: undefined,
} : {
  originData: () => [],
  getData: ({ pageno, count, keyword }) => {
    return Promise.resolve(genData({ pageno, count, keyword }));
  },
};

// 上面是调试示例用的代码

const ONE_LOAD = 10; // 每次加载10条

const LAZY_LOAD_COMP = 2; // 倒数第几个开始新的加载

export default {
  name: 'LazyLoadSelect', // 懒加载下拉菜单,支持前端分批处理或后端分批处理
  props: {
    originData: {
      type: Array,
      default: defaultProps.originData,
    },
    getData: {
      type: Function,
      default: defaultProps.getData,
    },
  },
  data() {
    return {
      currentValue: 0,
      active: false,
      searchText: '',
      displayData: [],
      filterData: [],
      intersectionObserver: null,
    };
  },
  computed: {
    displayLabel() {
      const selectd = this.originData.find(
        item => item.value === this.currentValue
      );
      return selectd ? selectd.label : '请选择';
    },
    useRemoteData() {
      return typeof this.getData === 'function';
    },
    count() {
      return ONE_LOAD;
    },
    pageno() {
      return this.displayData.length / this.count || 1;
    },
  },
  methods: {
    async initFilterData(keyword) {
      this.displayData = [];
      if (this.useRemoteData) {
        // 如果有外部方法,初始化则不需要过滤,直接更新接口来的数据即可
        const data = await this.loadRemoteData();
        this.displayData = data;
      } else {
        // 如果没有外部方法,初始化则先根据搜索词过滤得出过滤数据,然后再对过滤数据分批
        this.filterData = this.originData.filter(item => {
          return keyword ? item.label.indexOf(keyword) !== -1 : true;
        });
        this.displayData = this.filterData.slice(0, 0 + ONE_LOAD);
      }
      this.$selectUl && (this.$selectUl.scrollTop = 0);
      this.updateObserveLayzload();
    },
    search(event) {
      // 应防抖
      this.initFilterData(this.searchText);
    },
    select(value) {
      this.currentValue = value;
      this.active = false;
      this.$emit('change', value);
    },
    updateObserveLayzload() {
      this.$nextTick(() => {
        if (this.displayData.length >= this.filterData.length && !this.useRemoteData) {
          this.$lazyLoadLi &&
            this.intersectionObserver.unobserve(this.$lazyLoadLi);
          return;
        }
        const $liArr = this.$refs['select-li'];

        this.$lazyLoadLi &&
          this.intersectionObserver.unobserve(this.$lazyLoadLi);
        this.$lazyLoadLi = $liArr[$liArr.length - 1 - LAZY_LOAD_COMP];
        this.intersectionObserver.observe(this.$lazyLoadLi);
      });
    },
    async loadRemoteData() { // 从外部方法获取数据
      try {
        const data = await this.getData(
            { pageno: this.pageno, count: this.count, keyword: this.searchText }
        );
        return data;
      } catch(error) {
        console.error('get data error:', error);
      }
      return [];
    },
    async getNewBatchData() {
      if (this.useRemoteData) {
        // 数据(包括筛选)完全由后端处理
        const addData = await this.loadRemoteData();
        return addData;
      }
      // 由前端对数据分批
      const addData = this.filterData.slice(
        this.displayData.length,
        this.displayData.length + ONE_LOAD,
      );
      return addData;
    },
    async updateData() {
      const addData = await this.getNewBatchData();
      this.displayData.push(...addData);
      this.updateObserveLayzload();
    },
  },
  created() {
    this.initFilterData('');
  },
  mounted() {
    this.$selectUl = this.$refs['select-ul'];
    this.intersectionObserver = new IntersectionObserver(entries => {
      if (entries[0].intersectionRatio) {
        this.updateData();
      }
    });

    const $main = this.$refs['lazy-load-select'];
    window.addEventListener(
      'click',
      (ev) => {
        // 判断是否点击在 select 组件上
        const isSelectComp = ev.composedPath().find(dom => $main === dom);
        if (!isSelectComp) {
          this.active = false;
        }
      }
    );
  }
};
</script>

Read More

vue 的里 observer、dep、watcher

简单梳理一下 vue 源码里,observer、dep、watcher 这三者的作用,捋清了基本上就知道 vue 的响应式操作了。看一张图:

vue-observer-dep-watcher.jpg

总结一下就是,我们在 vue 文件定义数据 data 属性,会生成一个 _data 的对象,遍历这个 _data 对象的属性,会通过 defineProperty 挂载到 vue 实例上。以图里 src 为例:

  • vm.src,被 defineProperty 处理
  • => 读取和设置都是 vm._data.src
  • => vm._data.src 被 defineProperty 处理
  • => 在其 set get 函数进行依赖收集即更新通知
再细述两个小点
  1. 原来 dom 的更新是在 watcher 的 getter 里。watcher 有两种,一种是在实例上声明的 watch 对象或者主动调用 vm.$watch 方法的,这种都是属性 user=== true 的 watcher,其 getter 都是直接获取 vm 上的值。一种是生命周期由框架生成的 watcher,这个的 getter 是 updateComponent,里面包含了对 dom 的更新。当然接下来就是 diff 算法更新 dom 的事了,不属于依赖更新的内容。

  2. vue 文件的 template 代码会被解析成 render 函数,对,就是那个说直接写会效率更快的 render 函数。原过程是:template => ast => render,这一步还是比较消耗。直接写 render 函数相当于省去前面两步。然后 render 执行后会生成虚拟 dom,也就是 VNode。接下来就是 vm.patch 里通过双指针的 diff 算法,来对比新旧 VNode 的差别,算出变更的地方,然后执行 js 原生的元素操作语句进行 dom 的修改。

二叉树前序中序后序遍历

二叉树的遍历,详细概念还是看搜索引擎总结吧,二叉树遍历

二叉树,前序、中序、后序,遍历,都是深度优先遍历
前中后指的是根节点的访问顺序
对最底层的节点而言,前中后就是三个节点
对于非最底层的节点而言,左右两个节点就是分支,不仅仅是三个节点

const tree = {
  value: 1,
  left: {
    value: 2,
    left: {
      value: 4,
      left: {
        value: 8,
      },
      right: {
        value: 9,
      },
    },
    right: {
      value: 5,
      left: {
        value: 10,
      },
      right: {
        value: 11,
      },
    },
  },
  right: {
    value: 3,
    left: {
      value: 6,
      left: {
        value: 12,
      },
      right: {
        value: 13,
      },
    },
    right: {
      value: 7,
      left: {
        value: 14,
      },
      right: {
        value: 15,
      },
    },
  },
};

Read More