Near_Li

Near_Li 查看完整档案

广州编辑华南师范大学  |  电子信息工程 编辑  |  填写所在公司/组织填写个人主网站
编辑

前端似海望无际,以何作舟最可行

个人动态

Near_Li 收藏了文章 · 2月23日

优雅的elementUI table 单元格可编辑实现方法

最近在做可编辑特定列的单元格的elementUI table,看了N多的开源、文章,找到一个很优雅的实现方式,分享给大家。
PS:单元格可编辑的table,用英文搜索:Inline editable table with ElementUI 会得到高质量结果。

先上效果:
图片描述

APP.vue:

<template>
  <div id="app">
      <div style="margin-bottom: 30px">
        <el-switch
            style="display: block"
            v-model="editModeEnabled"
            active-color="#13ce66"
            inactive-color="#ff4949"
            active-text="Edit enabled"
            inactive-text="Edit disabled">
          </el-switch>
      </div>
       <el-table
      :data="gridData"
      style="width: 100%">
      <el-table-column
        label="Name"
        min-width="180">
        <editable-cell slot-scope="{row}"
                       :can-edit="editModeEnabled"
                       v-model="row.name">
          <span slot="content">{{row.name}}</span>
        </editable-cell>
      </el-table-column>

      <el-table-column
        min-wwidth="150"
        label="Gender">

         <editable-cell 
         slot-scope="{row}" 
         editable-component="el-select"
         :can-edit="editModeEnabled"
         close-event="change"
         v-model="row.gender">
         
          <el-tag size="medium" 
                  :type="row.gender === 'M' ? 'primary' : 'danger'" 
                  slot="content">
                  {{row.gender === 'M' ? 'Male': 'Female'}}
          </el-tag>

          <template slot="edit-component-slot">
            <el-option value="M" label="Male"></el-option>
            <el-option value="F" label="Female"></el-option>
          </template>
        </editable-cell>
        
      </el-table-column>


      <el-table-column
        label="Birth Date"
        min-width="250">
         <editable-cell 
         slot-scope="{row}" 
         :can-edit="editModeEnabled"
         editable-component="el-date-picker"
         format="yyyy-MM-dd"
         value-format="yyyy-MM-dd"
         v-model="row.date">
          <span slot="content">{{row.date}}</span>
        </editable-cell>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
import EditableCell from "./components/EditableCell.vue";

export default {
  name: "App",
  components: {
    EditableCell
  },
  data() {
    return {
      editModeEnabled: false,
      gridData: [
        {
          date: "2016-05-03",
          name: "Tom",
          gender: "M"
        },
        {
          date: "2016-05-02",
          name: "Lisa",
          gender: "F"
        },
        {
          date: "2016-05-04",
          name: "Jon",
          gender: "M"
        },
        {
          date: "2016-05-01",
          name: "Mary",
          gender: "F"
        }
      ]
    };
  }
};
</script>

<style>
.edit-cell {
  min-height: 35px;
  cursor: pointer;
}
</style>

EditeableCell.vue:

<template>
  <div @click="onFieldClick" class="edit-cell">
    <el-tooltip v-if="!editMode && !showInput"
                :placement="toolTipPlacement"
                :open-delay="toolTipDelay"
                :content="toolTipContent">
      <div tabindex="0" 
           class="cell-content"
           :class="{'edit-enabled-cell': canEdit}"
           @keyup.enter="onFieldClick">
        <slot name="content"></slot>
      </div>

    </el-tooltip>
    <component :is="editableComponent"
               v-if="editMode || showInput"
              ref="input"
              @focus="onFieldClick"
              @keyup.enter.native="onInputExit"
              v-on="listeners"
              v-bind="$attrs"
              v-model="model">
        <slot name="edit-component-slot"></slot>
    </component>
  </div>
</template>
<script>
export default {
  name: "editable-cell",
  inheritAttrs: false,
  props: {
    value: {
      type: String,
      default: ""
    },
    toolTipContent: {
      type: String,
      default: "Click to edit"
    },
    toolTipDelay: {
      type: Number,
      default: 500
    },
    toolTipPlacement: {
      type: String,
      default: "top-start"
    },
    showInput: {
      type: Boolean,
      default: false
    },
    editableComponent: {
      type: String,
      default: "el-input"
    },
    closeEvent: {
      type: String,
      default: "blur"
    },
    canEdit: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      editMode: false
    };
  },
  computed: {
    model: {
      get() {
        return this.value;
      },
      set(val) {
        this.$emit("input", val);
      }
    },
    listeners() {
      return {
        [this.closeEvent]: this.onInputExit,
        ...this.$listeners
      };
    }
  },
  methods: {
    onFieldClick() {
      if (this.canEdit) {
        this.editMode = true;
        this.$nextTick(() => {
          let inputRef = this.$refs.input;
          if (inputRef && inputRef.focus) {
            inputRef.focus();
          }
        });
      }
    },
    onInputExit() {
      this.editMode = false;
    },
    onInputChange(val) {
      this.$emit("input", val);
    }
  }
};
</script>
<style>
.cell-content {
  min-height: 40px;
  padding-left: 5px;
  padding-top: 5px;
  border: 1px solid transparent;
}
.edit-enabled-cell {
  border: 1px dashed #409eff;
}
</style>

在线查看(可能需要科学上网):https://codesandbox.io/s/2pv1...
github:https://github.com/heianxing/...

另外一个单元格编辑的例子:
图片描述

App.vue:

<template>
  <div id="app">
      <el-tooltip content="Click on any of the cells or on the edit button to edit content">
       <i class="el-icon-info"></i>
      </el-tooltip>
       <el-table
      :data="gridData"
      style="width: 100%">

       <el-table-column
        label="Operations"
        min-width="180">
        <template slot-scope="{row, index}">
         <el-button icon="el-icon-edit"
          @click="setEditMode(row, index)">
        </el-button>
         <el-button type="success" icon="el-icon-check"
          @click="saveRow(row, index)">
        </el-button>
        </template>
      </el-table-column>


      <el-table-column
        label="Name"
        min-width="180">
        <editable-cell :show-input="row.editMode" slot-scope="{row}" v-model="row.name">
          <span slot="content">{{row.name}}</span>
        </editable-cell>
      </el-table-column>

      <el-table-column
        min-wwidth="150"
        label="Gender">

         <editable-cell 
         :show-input="row.editMode"
         slot-scope="{row}" 
         editable-component="el-select"
         close-event="change"
         v-model="row.gender">
         
          <el-tag size="medium" 
                  :type="row.gender === 'M' ? 'primary' : 'danger'" 
                  slot="content">
                  {{row.gender === 'M' ? 'Male': 'Female'}}
          </el-tag>

          <template slot="edit-component-slot">
            <el-option value="M" label="Male"></el-option>
            <el-option value="F" label="Female"></el-option>
          </template>
        </editable-cell>
        
      </el-table-column>


      <el-table-column
        label="Birth Date"
        min-width="250">
         <editable-cell 
         :show-input="row.editMode"
         slot-scope="{row}" 
         editable-component="el-date-picker"
         format="yyyy-MM-dd"
         value-format="yyyy-MM-dd"
         v-model="row.date">
          <span slot="content">{{row.date}}</span>
        </editable-cell>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
import EditableCell from "./components/EditableCell.vue";

export default {
  name: "App",
  components: {
    EditableCell
  },
  data() {
    return {
      gridData: [
        {
          date: "2016-05-03",
          name: "Tom",
          gender: "M"
        },
        {
          date: "2016-05-02",
          name: "Lisa",
          gender: "F"
        },
        {
          date: "2016-05-04",
          name: "Jon",
          gender: "M"
        },
        {
          date: "2016-05-01",
          name: "Mary",
          gender: "F"
        }
      ]
    };
  },
  methods: {
    setEditMode(row, index) {
      row.editMode = true;
    },
    saveRow(row, index) {
      row.editMode = false;
    }
  },
  mounted() {
    this.gridData = this.gridData.map(row => {
      return {
        ...row,
        editMode: false
      };
    });
  }
};
</script>

<style>
.edit-cell {
  min-height: 35px;
  cursor: pointer;
}
</style>

EditeableCell.vue:

<template>
  <div @click="onFieldClick" class="edit-cell">
    <el-tooltip v-if="!editMode && !showInput"
                :placement="toolTipPlacement"
                :open-delay="toolTipDelay"
                :content="toolTipContent">
      <div tabindex="0" @keyup.enter="onFieldClick">
        <slot name="content"></slot>
      </div>

    </el-tooltip>
    <component :is="editableComponent"
               v-if="editMode || showInput"
              ref="input"
              @focus="onFieldClick"
              @keyup.enter.native="onInputExit"
              v-on="listeners"
              v-bind="$attrs"
              v-model="model">
        <slot name="edit-component-slot"></slot>
    </component>
  </div>
</template>
<script>
export default {
  name: "editable-cell",
  inheritAttrs: false,
  props: {
    value: {
      type: String,
      default: ""
    },
    toolTipContent: {
      type: String,
      default: "Click to edit"
    },
    toolTipDelay: {
      type: Number,
      default: 500
    },
    toolTipPlacement: {
      type: String,
      default: "top-start"
    },
    showInput: {
      type: Boolean,
      default: false
    },
    editableComponent: {
      type: String,
      default: "el-input"
    },
    closeEvent: {
      type: String,
      default: "blur"
    }
  },
  data() {
    return {
      editMode: false
    };
  },
  computed: {
    model: {
      get() {
        return this.value;
      },
      set(val) {
        this.$emit("input", val);
      }
    },
    listeners() {
      return {
        [this.closeEvent]: this.onInputExit,
        ...this.$listeners
      };
    }
  },
  methods: {
    onFieldClick() {
      this.editMode = true;
      this.$nextTick(() => {
        let inputRef = this.$refs.input;
        if (inputRef) {
          inputRef.focus();
        }
      });
    },
    onInputExit() {
      this.editMode = false;
    },
    onInputChange(val) {
      this.$emit("input", val);
    }
  }
};
</script>
<style>

</style>

在线查看(可能需要科学上网):https://codesandbox.io/s/mrqq...
github:https://github.com/heianxing/...

英文来源:https://www.reddit.com/r/vuej...

查看原文

Near_Li 赞了文章 · 2月23日

优雅的elementUI table 单元格可编辑实现方法

最近在做可编辑特定列的单元格的elementUI table,看了N多的开源、文章,找到一个很优雅的实现方式,分享给大家。
PS:单元格可编辑的table,用英文搜索:Inline editable table with ElementUI 会得到高质量结果。

先上效果:
图片描述

APP.vue:

<template>
  <div id="app">
      <div style="margin-bottom: 30px">
        <el-switch
            style="display: block"
            v-model="editModeEnabled"
            active-color="#13ce66"
            inactive-color="#ff4949"
            active-text="Edit enabled"
            inactive-text="Edit disabled">
          </el-switch>
      </div>
       <el-table
      :data="gridData"
      style="width: 100%">
      <el-table-column
        label="Name"
        min-width="180">
        <editable-cell slot-scope="{row}"
                       :can-edit="editModeEnabled"
                       v-model="row.name">
          <span slot="content">{{row.name}}</span>
        </editable-cell>
      </el-table-column>

      <el-table-column
        min-wwidth="150"
        label="Gender">

         <editable-cell 
         slot-scope="{row}" 
         editable-component="el-select"
         :can-edit="editModeEnabled"
         close-event="change"
         v-model="row.gender">
         
          <el-tag size="medium" 
                  :type="row.gender === 'M' ? 'primary' : 'danger'" 
                  slot="content">
                  {{row.gender === 'M' ? 'Male': 'Female'}}
          </el-tag>

          <template slot="edit-component-slot">
            <el-option value="M" label="Male"></el-option>
            <el-option value="F" label="Female"></el-option>
          </template>
        </editable-cell>
        
      </el-table-column>


      <el-table-column
        label="Birth Date"
        min-width="250">
         <editable-cell 
         slot-scope="{row}" 
         :can-edit="editModeEnabled"
         editable-component="el-date-picker"
         format="yyyy-MM-dd"
         value-format="yyyy-MM-dd"
         v-model="row.date">
          <span slot="content">{{row.date}}</span>
        </editable-cell>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
import EditableCell from "./components/EditableCell.vue";

export default {
  name: "App",
  components: {
    EditableCell
  },
  data() {
    return {
      editModeEnabled: false,
      gridData: [
        {
          date: "2016-05-03",
          name: "Tom",
          gender: "M"
        },
        {
          date: "2016-05-02",
          name: "Lisa",
          gender: "F"
        },
        {
          date: "2016-05-04",
          name: "Jon",
          gender: "M"
        },
        {
          date: "2016-05-01",
          name: "Mary",
          gender: "F"
        }
      ]
    };
  }
};
</script>

<style>
.edit-cell {
  min-height: 35px;
  cursor: pointer;
}
</style>

EditeableCell.vue:

<template>
  <div @click="onFieldClick" class="edit-cell">
    <el-tooltip v-if="!editMode && !showInput"
                :placement="toolTipPlacement"
                :open-delay="toolTipDelay"
                :content="toolTipContent">
      <div tabindex="0" 
           class="cell-content"
           :class="{'edit-enabled-cell': canEdit}"
           @keyup.enter="onFieldClick">
        <slot name="content"></slot>
      </div>

    </el-tooltip>
    <component :is="editableComponent"
               v-if="editMode || showInput"
              ref="input"
              @focus="onFieldClick"
              @keyup.enter.native="onInputExit"
              v-on="listeners"
              v-bind="$attrs"
              v-model="model">
        <slot name="edit-component-slot"></slot>
    </component>
  </div>
</template>
<script>
export default {
  name: "editable-cell",
  inheritAttrs: false,
  props: {
    value: {
      type: String,
      default: ""
    },
    toolTipContent: {
      type: String,
      default: "Click to edit"
    },
    toolTipDelay: {
      type: Number,
      default: 500
    },
    toolTipPlacement: {
      type: String,
      default: "top-start"
    },
    showInput: {
      type: Boolean,
      default: false
    },
    editableComponent: {
      type: String,
      default: "el-input"
    },
    closeEvent: {
      type: String,
      default: "blur"
    },
    canEdit: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      editMode: false
    };
  },
  computed: {
    model: {
      get() {
        return this.value;
      },
      set(val) {
        this.$emit("input", val);
      }
    },
    listeners() {
      return {
        [this.closeEvent]: this.onInputExit,
        ...this.$listeners
      };
    }
  },
  methods: {
    onFieldClick() {
      if (this.canEdit) {
        this.editMode = true;
        this.$nextTick(() => {
          let inputRef = this.$refs.input;
          if (inputRef && inputRef.focus) {
            inputRef.focus();
          }
        });
      }
    },
    onInputExit() {
      this.editMode = false;
    },
    onInputChange(val) {
      this.$emit("input", val);
    }
  }
};
</script>
<style>
.cell-content {
  min-height: 40px;
  padding-left: 5px;
  padding-top: 5px;
  border: 1px solid transparent;
}
.edit-enabled-cell {
  border: 1px dashed #409eff;
}
</style>

在线查看(可能需要科学上网):https://codesandbox.io/s/2pv1...
github:https://github.com/heianxing/...

另外一个单元格编辑的例子:
图片描述

App.vue:

<template>
  <div id="app">
      <el-tooltip content="Click on any of the cells or on the edit button to edit content">
       <i class="el-icon-info"></i>
      </el-tooltip>
       <el-table
      :data="gridData"
      style="width: 100%">

       <el-table-column
        label="Operations"
        min-width="180">
        <template slot-scope="{row, index}">
         <el-button icon="el-icon-edit"
          @click="setEditMode(row, index)">
        </el-button>
         <el-button type="success" icon="el-icon-check"
          @click="saveRow(row, index)">
        </el-button>
        </template>
      </el-table-column>


      <el-table-column
        label="Name"
        min-width="180">
        <editable-cell :show-input="row.editMode" slot-scope="{row}" v-model="row.name">
          <span slot="content">{{row.name}}</span>
        </editable-cell>
      </el-table-column>

      <el-table-column
        min-wwidth="150"
        label="Gender">

         <editable-cell 
         :show-input="row.editMode"
         slot-scope="{row}" 
         editable-component="el-select"
         close-event="change"
         v-model="row.gender">
         
          <el-tag size="medium" 
                  :type="row.gender === 'M' ? 'primary' : 'danger'" 
                  slot="content">
                  {{row.gender === 'M' ? 'Male': 'Female'}}
          </el-tag>

          <template slot="edit-component-slot">
            <el-option value="M" label="Male"></el-option>
            <el-option value="F" label="Female"></el-option>
          </template>
        </editable-cell>
        
      </el-table-column>


      <el-table-column
        label="Birth Date"
        min-width="250">
         <editable-cell 
         :show-input="row.editMode"
         slot-scope="{row}" 
         editable-component="el-date-picker"
         format="yyyy-MM-dd"
         value-format="yyyy-MM-dd"
         v-model="row.date">
          <span slot="content">{{row.date}}</span>
        </editable-cell>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
import EditableCell from "./components/EditableCell.vue";

export default {
  name: "App",
  components: {
    EditableCell
  },
  data() {
    return {
      gridData: [
        {
          date: "2016-05-03",
          name: "Tom",
          gender: "M"
        },
        {
          date: "2016-05-02",
          name: "Lisa",
          gender: "F"
        },
        {
          date: "2016-05-04",
          name: "Jon",
          gender: "M"
        },
        {
          date: "2016-05-01",
          name: "Mary",
          gender: "F"
        }
      ]
    };
  },
  methods: {
    setEditMode(row, index) {
      row.editMode = true;
    },
    saveRow(row, index) {
      row.editMode = false;
    }
  },
  mounted() {
    this.gridData = this.gridData.map(row => {
      return {
        ...row,
        editMode: false
      };
    });
  }
};
</script>

<style>
.edit-cell {
  min-height: 35px;
  cursor: pointer;
}
</style>

EditeableCell.vue:

<template>
  <div @click="onFieldClick" class="edit-cell">
    <el-tooltip v-if="!editMode && !showInput"
                :placement="toolTipPlacement"
                :open-delay="toolTipDelay"
                :content="toolTipContent">
      <div tabindex="0" @keyup.enter="onFieldClick">
        <slot name="content"></slot>
      </div>

    </el-tooltip>
    <component :is="editableComponent"
               v-if="editMode || showInput"
              ref="input"
              @focus="onFieldClick"
              @keyup.enter.native="onInputExit"
              v-on="listeners"
              v-bind="$attrs"
              v-model="model">
        <slot name="edit-component-slot"></slot>
    </component>
  </div>
</template>
<script>
export default {
  name: "editable-cell",
  inheritAttrs: false,
  props: {
    value: {
      type: String,
      default: ""
    },
    toolTipContent: {
      type: String,
      default: "Click to edit"
    },
    toolTipDelay: {
      type: Number,
      default: 500
    },
    toolTipPlacement: {
      type: String,
      default: "top-start"
    },
    showInput: {
      type: Boolean,
      default: false
    },
    editableComponent: {
      type: String,
      default: "el-input"
    },
    closeEvent: {
      type: String,
      default: "blur"
    }
  },
  data() {
    return {
      editMode: false
    };
  },
  computed: {
    model: {
      get() {
        return this.value;
      },
      set(val) {
        this.$emit("input", val);
      }
    },
    listeners() {
      return {
        [this.closeEvent]: this.onInputExit,
        ...this.$listeners
      };
    }
  },
  methods: {
    onFieldClick() {
      this.editMode = true;
      this.$nextTick(() => {
        let inputRef = this.$refs.input;
        if (inputRef) {
          inputRef.focus();
        }
      });
    },
    onInputExit() {
      this.editMode = false;
    },
    onInputChange(val) {
      this.$emit("input", val);
    }
  }
};
</script>
<style>

</style>

在线查看(可能需要科学上网):https://codesandbox.io/s/mrqq...
github:https://github.com/heianxing/...

英文来源:https://www.reddit.com/r/vuej...

查看原文

赞 13 收藏 9 评论 1

Near_Li 赞了文章 · 1月5日

Vue + TypeScript + Element 项目实践(简洁时尚博客网站)及踩坑记

前言

本文讲解如何在 Vue 项目中使用 TypeScript 来搭建并开发项目,并在此过程中踩过的坑 。

TypeScript 具有类型系统,且是 JavaScript 的超集,TypeScript 在 2018年 势头迅猛,可谓遍地开花。

Vue3.0 将使用 TS 重写,重写后的 Vue3.0 将更好的支持 TS。2019 年 TypeScript 将会更加普及,能够熟练掌握 TS,并使用 TS 开发过项目,将更加成为前端开发者的优势。

所以笔者就当然也要学这个必备技能,就以 边学边实践 的方式,做个博客项目来玩玩。

此项目是基于 Vue 全家桶 + TypeScript + Element-UI 的技术栈,且已经开源,github 地址 blog-vue-typescript

因为之前写了篇纯 Vue 项目搭建的相关文章 基于vue+mint-ui的mobile-h5的项目说明 ,有不少人加我微信,要源码来学习,但是这个是我司的项目,不能提供原码。

所以做一个不是我司的项目,且又是 vue 相关的项目来练手并开源吧。

1. 效果

效果图:

  • pc 端

  • 移动端

完整效果请看:https://biaochenxuying.cn

2. 功能

已经完成功能

  • [x] 登录
  • [x] 注册
  • [x] 文章列表
  • [x] 文章归档
  • [x] 标签
  • [x] 关于
  • [x] 点赞与评论
  • [x] 留言
  • [x] 历程
  • [x] 文章详情(支持代码语法高亮)
  • [x] 文章详情目录
  • [x] 移动端适配
  • [x] github 授权登录

待优化或者实现

  • [ ] 使用 vuex-class
  • [ ] 更多 TypeScript 的优化技巧
  • [ ] 服务器渲染 SSR

3. 前端主要技术

所有技术都是当前最新的。

  • vue: ^2.6.6
  • typescript : ^3.2.1
  • element-ui: 2.6.3
  • vue-router : ^3.0.1
  • webpack: 4.28.4
  • vuex: ^3.0.1
  • axios:0.18.0
  • redux: 4.0.0
  • highlight.js: 9.15.6
  • marked:0.6.1

4. 5 分钟上手 TypeScript

如果没有一点点基础,可能没学过 TypeScript 的读者会看不懂往下的内容,所以先学点基础。

TypeScript 的静态类型检查是个好东西,可以避免很多不必要的错误, 不用在调试或者项目上线的时候才发现问题 。

  • 类型注解

TypeScript 里的类型注解是一种轻量级的为函数或变量添加约束的方式。变量定义时也要定义他的类型,比如常见的 :

// 布尔值
let isDone: boolean = false; // 相当于 js 的 let isDone = false;
// 变量定义之后不可以随便变更它的类型
isDone = true // 不报错
isDone = "我要变为字符串" // 报错
// 数字
let decLiteral: number = 6; // 相当于 js 的 let decLiteral = 6;
// 字符串
let name: string = "bob";  // 相当于 js 的 let name = "bob";
// 数组
 // 第一种,可以在元素类型后面接上 [],表示由此类型元素组成的一个数组:
let list: number[] = [1, 2, 3]; // 相当于 js 的let list = [1, 2, 3];
// 第二种方式是使用数组泛型,Array<元素类型>:
let list: Array<number> = [1, 2, 3]; // 相当于 js 的let list = [1, 2, 3];
// 在 TypeScript 中,我们使用接口(Interfaces)来定义 对象 的类型。
interface Person {
    name: string;
    age: number;
}
let tom: Person = {
    name: 'Tom',
    age: 25
};
// 以上 对象 的代码相当于 
let tom = {
    name: 'Tom',
    age: 25
};
// Any 可以随便变更类型 (当这个值可能来自于动态的内容,比如来自用户输入或第三方代码库)
let notSure: any = 4;
notSure = "我可以随便变更类型" // 不报错
notSure = false;  // 不报错
// Void 当一个函数没有返回值时,你通常会见到其返回值类型是 void
function warnUser(): void {
    console.log("This is my warning message");
}
// 方法的参数也要定义类型,不知道就定义为 any
function fetch(url: string, id : number, params: any): void {
    console.log("fetch");
}

以上是最简单的一些知识点,更多知识请看 TypeScript 中文官网

5. 5 分钟上手 Vue +TypeScript

vue-class-component 对 Vue 组件进行了一层封装,让 Vue 组件语法在结合了 TypeScript 语法之后更加扁平化:

<template>
  <div>
    <input v-model="msg">
    <p>prop: {{propMessage}}</p>
    <p>msg: {{msg}}</p>
    <p>helloMsg: {{helloMsg}}</p>
    <p>computed msg: {{computedMsg}}</p>
    <button @click="greet">Greet</button>
  </div>
</template>

<script>
import Vue from 'vue'
import Component from 'vue-class-component'

@Component({
  props: {
    propMessage: String
  }
})
export default class App extends Vue {
  // initial data
  msg = 123

  // use prop values for initial data
  helloMsg = 'Hello, ' + this.propMessage

  // lifecycle hook
  mounted () {
    this.greet()
  }

  // computed
  get computedMsg () {
    return 'computed ' + this.msg
  }

  // method
  greet () {
    alert('greeting: ' + this.msg)
  }
}
</script>

上面的代码跟下面的代码作用是一样的:

<template>
  <div>
    <input v-model="msg">
    <p>prop: {{propMessage}}</p>
    <p>msg: {{msg}}</p>
    <p>helloMsg: {{helloMsg}}</p>
    <p>computed msg: {{computedMsg}}</p>
    <button @click="greet">Greet</button>
  </div>
</template>

<script>
export default {
  // 属性
  props: {
    propMessage: {
      type: String
    }
  },
  data () {
    return {
      msg: 123,
      helloMsg: 'Hello, ' + this.propMessage
    }
  },
  // 声明周期钩子
  mounted () {
    this.greet()
  },
  // 计算属性
  computed: {
    computedMsg () {
      return 'computed ' + this.msg
    }
  },
  // 方法
  methods: {
    greet () {
      alert('greeting: ' + this.msg)
    }
  },
}
</script>

vue-property-decorator 是在 vue-class-component 上增强了更多的结合 Vue 特性的装饰器,新增了这 7 个装饰器:

  • @Emit
  • @Inject
  • @Model
  • @Prop
  • @Provide
  • @Watch
  • @Component (从 vue-class-component 继承)

在这里列举几个常用的@Prop/@Watch/@Component, 更多信息,详见官方文档

import { Component, Emit, Inject, Model, Prop, Provide, Vue, Watch } from 'vue-property-decorator'

@Component
export class MyComponent extends Vue {
  
  @Prop()
  propA: number = 1

  @Prop({ default: 'default value' })
  propB: string

  @Prop([String, Boolean])
  propC: string | boolean

  @Prop({ type: null })
  propD: any

  @Watch('child')
  onChildChanged(val: string, oldVal: string) { }
}

上面的代码相当于:

export default {
  props: {
    checked: Boolean,
    propA: Number,
    propB: {
      type: String,
      default: 'default value'
    },
    propC: [String, Boolean],
    propD: { type: null }
  }
  methods: {
    onChildChanged(val, oldVal) { }
  },
  watch: {
    'child': {
      handler: 'onChildChanged',
      immediate: false,
      deep: false
    }
  }
}
  • vuex-class

vuex-class :在 vue-class-component 写法中 绑定 vuex

import Vue from 'vue'
import Component from 'vue-class-component'
import {
  State,
  Getter,
  Action,
  Mutation,
  namespace
} from 'vuex-class'

const someModule = namespace('path/to/module')

@Component
export class MyComp extends Vue {
  @State('foo') stateFoo
  @State(state => state.bar) stateBar
  @Getter('foo') getterFoo
  @Action('foo') actionFoo
  @Mutation('foo') mutationFoo
  @someModule.Getter('foo') moduleGetterFoo

  // If the argument is omitted, use the property name
  // for each state/getter/action/mutation type
  @State foo
  @Getter bar
  @Action baz
  @Mutation qux

  created () {
    this.stateFoo // -> store.state.foo
    this.stateBar // -> store.state.bar
    this.getterFoo // -> store.getters.foo
    this.actionFoo({ value: true }) // -> store.dispatch('foo', { value: true })
    this.mutationFoo({ value: true }) // -> store.commit('foo', { value: true })
    this.moduleGetterFoo // -> store.getters['path/to/module/foo']
  }
}

6. 用 vue-cli 搭建 项目

笔者使用最新的 vue-cli 3 搭建项目,详细的教程,请看我之前写的 vue-cli3.x 新特性及踩坑记,里面已经有详细讲解 ,但文章里面的配置和此项目不同的是,我加入了 TypeScript ,其他的配置都是 vue-cli 本来配好的了。详情请看 vue-cli 官网

6.1 安装及构建项目目录

安装的依赖:

安装过程选择的一些配置:

搭建好之后,初始项目结构长这样:

├── public                          // 静态页面

├── src                             // 主目录

    ├── assets                      // 静态资源

    ├── components                  // 组件

    ├── views                       // 页面

    ├── App.vue                     // 页面主入口

    ├── main.ts                     // 脚本主入口

    ├── router.ts                   // 路由

    ├── shims-tsx.d.ts              // 相关 tsx 模块注入

    ├── shims-vue.d.ts              // Vue 模块注入

    └── store.ts                    // vuex 配置

├── tests                           // 测试用例

├── .eslintrc.js                    // eslint 相关配置

├── .gitignore                      // git 忽略文件配置

├── babel.config.js                 // babel 配置

├── postcss.config.js               // postcss 配置

├── package.json                    // 依赖

└── tsconfig.json                   // ts 配置

奔着 大型项目的结构 来改造项目结构,改造后 :


├── public                          // 静态页面

├── src                             // 主目录

    ├── assets                      // 静态资源

    ├── filters                     // 过滤

    ├── store                       // vuex 配置

    ├── less                        // 样式

    ├── utils                       // 工具方法(axios封装,全局方法等)

    ├── views                       // 页面

    ├── App.vue                     // 页面主入口

    ├── main.ts                     // 脚本主入口

    ├── router.ts                   // 路由

    ├── shime-global.d.ts           // 相关 全局或者插件 模块注入

    ├── shims-tsx.d.ts              // 相关 tsx 模块注入

    ├── shims-vue.d.ts              // Vue 模块注入, 使 TypeScript 支持 *.vue 后缀的文件

├── tests                           // 测试用例

├── .eslintrc.js                    // eslint 相关配置

├── postcss.config.js               // postcss 配置

├── .gitignore                      // git 忽略文件配置

├── babel.config.js                 // preset 记录

├── package.json                    // 依赖

├── README.md                       // 项目 readme

├── tsconfig.json                   // ts 配置

└── vue.config.js                   // webpack 配置

tsconfig.json 文件中指定了用来编译这个项目的根文件和编译选项。
本项目的 tsconfig.json 配置如下 :

{
    // 编译选项
  "compilerOptions": {
    // 编译输出目标 ES 版本
    "target": "esnext",
    // 采用的模块系统
    "module": "esnext",
    // 以严格模式解析
    "strict": true,
    "jsx": "preserve",
    // 从 tslib 导入外部帮助库: 比如__extends,__rest等
    "importHelpers": true,
    // 如何处理模块
    "moduleResolution": "node",
    // 启用装饰器
    "experimentalDecorators": true,
    "esModuleInterop": true,
    // 允许从没有设置默认导出的模块中默认导入
    "allowSyntheticDefaultImports": true,
    // 定义一个变量就必须给它一个初始值
    "strictPropertyInitialization" : false,
    // 允许编译javascript文件
    "allowJs": true,
    // 是否包含可以用于 debug 的 sourceMap
    "sourceMap": true,
    // 忽略 this 的类型检查, Raise error on this expressions with an implied any type.
    "noImplicitThis": false,
    // 解析非相对模块名的基准目录 
    "baseUrl": ".",
    // 给错误和消息设置样式,使用颜色和上下文。
    "pretty": true,
    // 设置引入的定义文件
    "types": ["webpack-env", "mocha", "chai"],
    // 指定特殊模块的路径
    "paths": {
      "@/*": ["src/*"]
    },
    // 编译过程中需要引入的库文件的列表
    "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
  },
  // ts 管理的文件
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "tests/**/*.ts",
    "tests/**/*.tsx"
  ],
  // ts 排除的文件
  "exclude": ["node_modules"]
}

更多配置请看官网的 tsconfig.json 的 编译选项

本项目的 vue.config.js:

const path = require("path");
const sourceMap = process.env.NODE_ENV === "development";

module.exports = {
  // 基本路径
  publicPath: "./",
  // 输出文件目录
  outputDir: "dist",
  // eslint-loader 是否在保存的时候检查
  lintOnSave: false,
  // webpack配置
  // see https://github.com/vuejs/vue-cli/blob/dev/docs/webpack.md
  chainWebpack: () => {},
  configureWebpack: config => {
    if (process.env.NODE_ENV === "production") {
      // 为生产环境修改配置...
      config.mode = "production";
    } else {
      // 为开发环境修改配置...
      config.mode = "development";
    }

    Object.assign(config, {
      // 开发生产共同配置
      resolve: {
        extensions: [".js", ".vue", ".json", ".ts", ".tsx"],
        alias: {
          vue$: "vue/dist/vue.js",
          "@": path.resolve(__dirname, "./src")
        }
      }
    });
  },
  // 生产环境是否生成 sourceMap 文件
  productionSourceMap: sourceMap,
  // css相关配置
  css: {
    // 是否使用css分离插件 ExtractTextPlugin
    extract: true,
    // 开启 CSS source maps?
    sourceMap: false,
    // css预设器配置项
    loaderOptions: {},
    // 启用 CSS modules for all css / pre-processor files.
    modules: false
  },
  // use thread-loader for babel & TS in production build
  // enabled by default if the machine has more than 1 cores
  parallel: require("os").cpus().length > 1,
  // PWA 插件相关配置
  // see https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa
  pwa: {},
  // webpack-dev-server 相关配置
  devServer: {
    open: process.platform === "darwin",
    host: "localhost",
    port: 3001, //8080,
    https: false,
    hotOnly: false,
    proxy: {
      // 设置代理
      // proxy all requests starting with /api to jsonplaceholder
      "/api": {
        // target: "https://emm.cmccbigdata.com:8443/",
        target: "http://localhost:3000/",
        // target: "http://47.106.136.114/",
        changeOrigin: true,
        ws: true,
        pathRewrite: {
          "^/api": ""
        }
      }
    },
    before: app => {}
  },
  // 第三方插件配置
  pluginOptions: {
    // ...
  }
};

6.2 安装 element-ui

本来想搭配 iview-ui 来用的,但后续还想把这个项目搞成 ssr 的,而 vue + typescript + iview + Nuxt.js 的服务端渲染还有不少坑, 而 vue + typescript + element + Nuxt.js 对 ssr 的支持已经不错了,所以选择了 element-ui 。

安装:

npm i element-ui -S

按需引入, 借助 babel-plugin-component,我们可以只引入需要的组件,以达到减小项目体积的目的。

npm install babel-plugin-component -D

然后,将 babel.config.js 修改为:

module.exports = {
  presets: ["@vue/app"],
  plugins: [
    [
      "component",
      {
        libraryName: "element-ui",
        styleLibraryName: "theme-chalk"
      }
    ]
  ]
};

接下来,如果你只希望引入部分组件,比如 Button 和 Select,那么需要在 main.js 中写入以下内容:

import Vue from 'vue';
import { Button, Select } from 'element-ui';
import App from './App.vue';

Vue.component(Button.name, Button);
Vue.component(Select.name, Select);
/* 或写为
 * Vue.use(Button)
 * Vue.use(Select)
 */

new Vue({
  el: '#app',
  render: h => h(App)
});

6.3 完善项目目录与文件

route

使用路由懒加载功能。

export default new Router({
  mode: "history",
  routes: [
    {
      path: "/",
      name: "home",
      component: () => import(/* webpackChunkName: "home" */ "./views/home.vue")
    },
    {
      path: "/articles",
      name: "articles",
      // route level code-splitting
      // this generates a separate chunk (articles.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () =>
        import(/* webpackChunkName: "articles" */ "./views/articles.vue")
    },
  ]
});

utils

  • utils/utils.ts 常用函数的封装, 比如 事件的节流(throttle)与防抖(debounce)方法:
// fn是我们需要包装的事件回调, delay是时间间隔的阈值
export function throttle(fn: Function, delay: number) {
  // last为上一次触发回调的时间, timer是定时器
  let last = 0,
    timer: any = null;
  // 将throttle处理结果当作函数返回
  return function() {
    // 保留调用时的this上下文
    let context = this;
    // 保留调用时传入的参数
    let args = arguments;
    // 记录本次触发回调的时间
    let now = +new Date();
    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last < delay) {
      // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
      clearTimeout(timer);
      timer = setTimeout(function() {
        last = now;
        fn.apply(context, args);
      }, delay);
    } else {
      // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
      last = now;
      fn.apply(context, args);
    }
  };
}
  • utils/config.ts 配置文件,比如 github 授权登录的回调地址、client_id、client_secret 等。
const config = {
  'oauth_uri': 'https://github.com/login/oauth/authorize',
  'redirect_uri': 'https://biaochenxuying.cn/login',
  'client_id': 'XXXXXXXXXX',
  'client_secret': 'XXXXXXXXXX',
};

// 本地开发环境下
if (process.env.NODE_ENV === 'development') {
  config.redirect_uri = "http://localhost:3001/login"
  config.client_id = "502176cec65773057a9e"
  config.client_secret = "65d444de381a026301a2c7cffb6952b9a86ac235"
}
export default config;

如果你的生产环境也要 github 登录授权的话,请在 github 上申请一个 Oauth App ,把你的 redirect_uri,client_id,client_secret 的信息填在 config 里面即可。具体详情请看我写的这篇文章 github 授权登录教程与如何设计第三方授权登录的用户表

  • utils/urls.ts 请求接口地址,统一管理。
// url的链接
export const urls: object = {
  login: "login",
  register: "register",
  getArticleList: "getArticleList",
};
export default urls;
  • utils/https.ts axios 请求的封装。
import axios from "axios";

// 创建axios实例
let service: any = {};
service = axios.create({
    baseURL: "/api", // api的base_url
    timeout: 50000 // 请求超时时间
  });

// request拦截器 axios的一些配置
service.interceptors.request.use(
  (config: any) => {
    return config;
  },
  (error: any) => {
    // Do something with request error
    console.error("error:", error); // for debug
    Promise.reject(error);
  }
);

// respone拦截器 axios的一些配置
service.interceptors.response.use(
  (response: any) => {
    return response;
  },
  (error: any) => {
    console.error("error:" + error); // for debug
    return Promise.reject(error);
  }
);

export default service;

把 urls 和 https 挂载到 main.ts 里面的 Vue 的 prototype 上面。

import service from "./utils/https";
import urls from "./utils/urls";

Vue.prototype.$https = service; // 其他页面在使用 axios 的时候直接  this.$http 就可以了
Vue.prototype.$urls = urls; // 其他页面在使用 urls 的时候直接  this.$urls 就可以了

然后就可以统一管理接口,而且调用起来也很方便啦。比如下面 文章列表的请求。

async handleSearch() {
    this.isLoading = true;
    const res: any = await this.$https.get(this.$urls.getArticleList, {
      params: this.params
    });
    this.isLoading = false;
    if (res.status === 200) {
      if (res.data.code === 0) {
        const data: any = res.data.data;
        this.articlesList = [...this.articlesList, ...data.list];
        this.total = data.count;
        this.params.pageNum++;
        if (this.total === this.articlesList.length) {
          this.isLoadEnd = true;
        }
      } else {
        this.$message({
          message: res.data.message,
          type: "error"
        });
      }
    } else {
      this.$message({
        message: "网络错误!",
        type: "error"
      });
    }
  }

store ( Vuex )

一般大型的项目都有很多模块的,比如本项目中有公共信息(比如 token )、 用户模块、文章模块。

├── modules                         // 模块

    ├── user.ts                     // 用户模块 
    
    ├── article.ts                 // 文章模块 

├── types.ts                        // 类型

└── index.ts                        // vuex 主入口
  • store/index.ts 存放公共的信息,并导入其他模块
import Vue from "vue";
import Vuex from "vuex";
import * as types from "./types";
import user from "./modules/user";
import article from "./modules/article";

Vue.use(Vuex);
const initPageState = () => {
  return {
    token: ""
  };
};
const store = new Vuex.Store({
  strict: process.env.NODE_ENV !== "production",
  // 具体模块
  modules: {
    user,
    article
  },
  state: initPageState(),
  mutations: {
    [types.SAVE_TOKEN](state: any, pageState: any) {
      for (const prop in pageState) {
        state[prop] = pageState[prop];
      }
    }
  },
  actions: {}
});

export default store;
  • types.ts
// 公共 token
export const SAVE_TOKEN = "SAVE_TOKEN";

// 用户
export const SAVE_USER = "SAVE_USER";
  • user.ts
import * as types from "../types";

const initPageState = () => {
  return {
    userInfo: {
      _id: "",
      name: "",
      avator: ""
    }
  };
};
const user = {
  state: initPageState(),
  mutations: {
    [types.SAVE_USER](state: any, pageState: any) {
      for (const prop in pageState) {
        state[prop] = pageState[prop];
      }
    }
  },
  actions: {}
};

export default user;

7. markdown 渲染

markdown 渲染效果图:

markdown 渲染效果图

markdown 渲染 采用了开源的 marked, 代码高亮用了 highlight.js 。

用法:

第一步:npm i marked highlight.js --save

npm i marked highlight.js --save

第二步: 导入封装成 markdown.js,将文章详情由字符串转成 html, 并抽离出文章目录。

marked 的封装 得感谢这位老哥。

const highlight = require("highlight.js");
const marked = require("marked");
const tocObj = {
  add: function(text, level) {
    var anchor = `#toc${level}${++this.index}`;
    this.toc.push({ anchor: anchor, level: level, text: text });
    return anchor;
  },
  // 使用堆栈的方式处理嵌套的ul,li,level即ul的嵌套层次,1是最外层
  // <ul>
  //   <li></li>
  //   <ul>
  //     <li></li>
  //   </ul>
  //   <li></li>
  // </ul>
  toHTML: function() {
    let levelStack = [];
    let result = "";
    const addStartUL = () => {
      result += '<ul class="anchor-ul" id="anchor-fix">';
    };
    const addEndUL = () => {
      result += "</ul>\n";
    };
    const addLI = (anchor, text) => {
      result +=
        '<li><a class="toc-link" href="#' + anchor + '">' + text + "<a></li>\n";
    };

    this.toc.forEach(function(item) {
      let levelIndex = levelStack.indexOf(item.level);
      // 没有找到相应level的ul标签,则将li放入新增的ul中
      if (levelIndex === -1) {
        levelStack.unshift(item.level);
        addStartUL();
        addLI(item.anchor, item.text);
      } // 找到了相应level的ul标签,并且在栈顶的位置则直接将li放在此ul下
      else if (levelIndex === 0) {
        addLI(item.anchor, item.text);
      } // 找到了相应level的ul标签,但是不在栈顶位置,需要将之前的所有level出栈并且打上闭合标签,最后新增li
      else {
        while (levelIndex--) {
          levelStack.shift();
          addEndUL();
        }
        addLI(item.anchor, item.text);
      }
    });
    // 如果栈中还有level,全部出栈打上闭合标签
    while (levelStack.length) {
      levelStack.shift();
      addEndUL();
    }
    // 清理先前数据供下次使用
    this.toc = [];
    this.index = 0;
    return result;
  },
  toc: [],
  index: 0
};

class MarkUtils {
  constructor() {
    this.rendererMD = new marked.Renderer();
    this.rendererMD.heading = function(text, level, raw) {
      var anchor = tocObj.add(text, level);
      return `<h${level} id=${anchor}>${text}</h${level}>\n`;
    };
    highlight.configure({ useBR: true });
    marked.setOptions({
      renderer: this.rendererMD,
      headerIds: false,
      gfm: true,
      tables: true,
      breaks: false,
      pedantic: false,
      sanitize: false,
      smartLists: true,
      smartypants: false,
      highlight: function(code) {
        return highlight.highlightAuto(code).value;
      }
    });
  }

  async marked(data) {
    if (data) {
      let content = await marked(data); // 文章内容
      let toc = tocObj.toHTML(); // 文章目录
      return { content: content, toc: toc };
    } else {
      return null;
    }
  }
}

const markdown = new MarkUtils();

export default markdown;

第三步: 使用

import markdown from "@/utils/markdown";

// 获取文章详情
async handleSearch() {
    const res: any = await this.$https.post(
      this.$urls.getArticleDetail,
      this.params
    );
    if (res.status === 200) {
      if (res.data.code === 0) {
        this.articleDetail = res.data.data;
       // 使用 marked 转换
        const article = markdown.marked(res.data.data.content);
        article.then((response: any) => {
          this.articleDetail.content = response.content;
          this.articleDetail.toc = response.toc;
        });
      } else {
        // ...
    } else {
     // ... 
    }
  }

// 渲染
<div id="content"
       class="article-detail"
       v-html="articleDetail.content">
</div>

第四步:引入 monokai_sublime 的 css 样式

<link href="http://cdn.bootcss.com/highlight.js/8.0/styles/monokai_sublime.min.css" rel="stylesheet">

第五步:对 markdown 样式的补充

如果不补充样式,是没有黑色背景的,字体大小等也会比较小,图片也不会居中显示

/*对 markdown 样式的补充*/
pre {
    display: block;
    padding: 10px;
    margin: 0 0 10px;
    font-size: 14px;
    line-height: 1.42857143;
    color: #abb2bf;
    background: #282c34;
    word-break: break-all;
    word-wrap: break-word;
    overflow: auto;
}
h1,h2,h3,h4,h5,h6{
    margin-top: 1em;
    /* margin-bottom: 1em; */
}
strong {
    font-weight: bold;
}

p > code:not([class]) {
    padding: 2px 4px;
    font-size: 90%;
    color: #c7254e;
    background-color: #f9f2f4;
    border-radius: 4px;
}
p img{
    /* 图片居中 */
    margin: 0 auto;
    display: flex;
}

#content {
    font-family: "Microsoft YaHei",  'sans-serif';
    font-size: 16px;
    line-height: 30px;
}

#content .desc ul,#content .desc ol {
    color: #333333;
    margin: 1.5em 0 0 25px;
}

#content .desc h1, #content .desc h2 {
    border-bottom: 1px solid #eee;
    padding-bottom: 10px;
}

#content .desc a {
    color: #009a61;
}

8. 注意点

  • 关于 页面

对于 关于 的页面,其实是一篇文章来的,根据文章类型 type 来决定的,数据库里面 type 为 3
的文章,只能有一篇就是 博主介绍 ;达到了想什么时候修改内容都可以。

所以当 当前路由 === '/about' 时就是请求类型为 博主介绍 的文章。

type: 3,  // 文章类型: 1:普通文章;2:是博主简历;3 :是博主简介;
  • 移动端适配

移动端使用 rem 单位适配。

// 屏幕适配( window.screen.width / 移动端设计稿宽 * 100)也即是 (window.screen.width / 750 * 100)  ——*100 为了方便计算。即 font-size 值是手机 deviceWidth 与设计稿比值的 100 倍
document.getElementsByTagName('html')[0].style.fontSize = window.screen.width / 7.5 + 'px';

如上:通过查询屏幕宽度,动态的设置 html 的 font-size 值,移动端的设计稿大多以宽为 750 px 来设置的。

比如在设计图上一个 150 * 250 的盒子(单位 px):

原本在 css 中的写法:

width: 150px;
heigth: 250px;

通过上述换算后,在 css 中对应的 rem 值只需要写:

width: 1.5rem; // 150 / 100 rem
heigth: 2.5rem; // 250 / 100 rem

如果你的移动端的设计稿是以宽为 1080 px 来设置的话,就用 window.screen.width / 10.8 吧。

9. 踩坑记

  • 1. 让 vue 识别全局方法/变量
  1. 我们经常在 main.ts 中给 vue.prototype 挂载实例或者内容,以方便在组件里面使用。
import service from "./utils/https";
import urls from "./utils/urls";

Vue.prototype.$https = service; // 其他页面在使用 axios 的时候直接  this.$http 就可以了
Vue.prototype.$urls = urls; // 其他页面在使用 urls 的时候直接  this.$urls 就可以了

然而当你在组件中直接 this.$http 或者 this.$urls 时会报错的,那是因为 $http 和 $urls 属性,并没有在 vue 实例中声明。

  1. 再比如使用 Element-uI 的 meesage。
import { Message } from "element-ui";

Vue.prototype.$message = Message;

之前用法如下图:

  this.$message({
    message: '恭喜你,这是一条成功消息',
    type: 'success'
  })

然而还是会报错的。

再比如 监听路由的变化:

import { Vue, Watch } from "vue-property-decorator";
import Component from "vue-class-component";
import { Route } from "vue-router";

@Component
export default class App extends Vue {

  @Watch("$route")
  routeChange(val: Route, oldVal: Route) {
      //  do something
  }
}

只是这样写的话,监听 $route 还是会报错的。

想要以上三种做法都正常执行,就还要补充如下内容:

在 src 下的 shims-vue.d.ts 中加入要挂载的内容。 表示 vue 里面的 this 下有这些东西。

import VueRouter, { Route } from "vue-router";

declare module "vue/types/vue" {
  interface Vue {
    $router: VueRouter; // 这表示this下有这个东西
    $route: Route;
    $https: any; // 不知道类型就定为 any 吧(偷懒)
    $urls: any;
    $Message: any;
  }
}
  • 2. 引入的模块要声明

比如 在组件里面使用 window.document 或者 document.querySelector 的时候会报错的,npm run build 不给通过。

再比如:按需引用 element 的组件与动画组件:

import { Button } from "element-ui";
import CollapseTransition from "element-ui/lib/transitions/collapse-transition";

npm run serve 时可以执行,但是在 npm run build 的时候,会直接报错的,因为没有声明。

正确做法:

我在 src 下新建一个文件 shime-global.d.ts ,加入内容如下:

// 声明全局的 window ,不然使用 window.XX 时会报错
declare var window: Window;
declare var document: Document;

declare module "element-ui/lib/transitions/collapse-transition";
declare module "element-ui";

当然,这个文件你加在其他地方也可以,起其他名字都 OK。

但是即使配置了以上方法之后,有些地方使用 document.XXX ,比如 document.title 的时候,npm run build 还是通过不了,所以只能这样了:

<script lang="ts">
// 在用到 document.XXX  的文件中声明一下即可
declare var document: any;
// 此处省略 XXXX 多的代码
</script>
  • 3. this 的类型检查

比如之前的 事件的节流(throttle)与防抖(debounce)方法:

export function throttle(fn: Function, delay: number) {
  return function() {
    // 保留调用时的 this 上下文
    let context = this;
}

function 里面的 this 在 npm run serve 时会报错的,因为 tyescript 检测到它不是在类(class)里面。

正确做法:

在根目录的 tsconfig.json 里面加上 "noImplicitThis": false ,忽略 this 的类型检查。

// 忽略 this 的类型检查, Raise error on this expressions with an implied any type.
"noImplicitThis": false,
  • 4. import 的 .vue 文件

import .vue 的文件的时候,要补全 .vue 的后缀,不然 npm run build 会报错的。

比如:

import Nav from "@/components/nav"; // @ is an alias to /src
import Footer from "@/components/footer"; // @ is an alias to /src

要修改为:

import Nav from "@/components/nav.vue"; // @ is an alias to /src
import Footer from "@/components/footer.vue"; // @ is an alias to /src
  • 5. 装饰器 @Component

报错。

<script lang="ts">
import { Vue, Component } from "vue-property-decorator";
export default class LoadingCustom extends Vue {}
</script>

以下才是正确,因为这里的 Vue 是从 vue-property-decorator import 来的。

<script lang="ts">
import { Vue, Component } from "vue-property-decorator";

@Component
export default class LoadingCustom extends Vue {}
</script>
  • 6. 路由的组件导航守卫失效

vue-class-component 官网里面的路由的导航钩子的用法是没有效果的 Adding Custom Hooks

路由的导航钩子不属于 Vue 本身,这会导致 class 组件转义到配置对象时导航钩子无效,因此如果要使用导航钩子需要在 router 的配置里声明(网上别人说的,还没实践,不确定是否可行)。

  • 7. tsconfig.json 的 strictPropertyInitialization 设为 false,不然你定义一个变量就必须给它一个初始值。
  • position: sticky;

本项目中的文章详情的目录就是用了 sticky。

.anchor {
  position: sticky;
  top: 213px;
  margin-top: 213px;
}

position:sticky 是 css 定位新增属性;可以说是相对定位 relative 和固定定位 fixed 的结合;它主要用在对 scroll 事件的监听上;简单来说,在滑动过程中,某个元素距离其父元素的距离达到 sticky 粘性定位的要求时(比如 top:100px );position:sticky 这时的效果相当于 fixed 定位,固定到适当位置。

用法像上面那样用即可,但是有使用条件:

1、父元素不能 overflow:hidden 或者 overflow:auto 属性。
2、必须指定 top、bottom、left、right 4 个值之一,否则只会处于相对定位
3、父元素的高度不能低于 sticky 元素的高度
4、sticky 元素仅在其父元素内生效

  • 8. eslint 报找不到文件和装饰器的错

App.vue 中只是写了引用文件而已,而且 webpack 和 tsconfig.josn 里面已经配置了别名了的。

import Nav from "@/components/nav.vue"; // @ is an alias to /src
import Slider from "@/components/slider.vue"; // @ is an alias to /src
import Footer from "@/components/footer.vue"; // @ is an alias to /src
import ArrowUp from "@/components/arrowUp.vue"; // @ is an alias to /src
import { isMobileOrPc } from "@/utils/utils";

但是,还是会报如下的错:

只是代码不影响文件的打包,而且本地与生产环境的代码也正常,没报错而已。

这个 eslint 的检测目前还没找到相关的配置可以把这些错误去掉。

  • 9. 路由模式修改为 history

因为文章详情页面有目录,点击目录时定位定相应的内容,但是这个目录定位内容是根据锚点来做的,如果路由模式为 hash 模式的话,本来文章详情页面的路由就是 #articleDetail 了,再点击目录的话(比如 #title2 ),会在 #articleDetail 后面再加上 #title2,一刷新会找不到这个页面的。

10. Build Setup

 # clone
git clone https://github.com/biaochenxuying/blog-vue-typescript.git
# cd
cd  blog-vue-typescript
# install dependencies
npm install
# Compiles and hot-reloads for development
npm run serve
# Compiles and minifies for production
npm run build
### Run your tests
npm run test
### Lints and fixes files
npm run lint
### Run your unit tests
npm run test:unit
  • Customize configuration

See Configuration Reference.

如果要看有后台数据完整的效果,是要和后台项目 blog-node 一起运行才行的,不然接口请求会失败。

虽然引入了 mock 了,但是还没有时间做模拟数据,想看具体效果,请稳步到我的网站上查看 https://biaochenxuying.cn

11. 项目地址与系列相关文章

基于 Vue + TypeScript + Element 的 blog-vue-typescript 前台展示: https://github.com/biaochenxuying/blog-vue-typescript

基于 react + node + express + ant + mongodb 的博客前台,这个是笔者之前做的,效果和这个类似,地址如下:
blog-react 前台展示: https://github.com/biaochenxuying/blog-react

推荐阅读 :

本博客系统的系列文章:

12. 最后

笔者也是初学 TS ,如果文章有错的地方,请指出,感谢。

一开始用 Vue + TS 来搭建时,我也是挺抵触的,因为踩了好多坑,而且很多类型检查方面也挺烦人。后面解决了,明白原理之后,是越用越爽,哈哈。

权衡

如何更好的利用 JS 的动态性和 TS 的静态特质,我们需要结合项目的实际情况来进行综合判断。一些建议:

  • 如果是中小型项目,且生命周期不是很长,那就直接用 JS 吧,不要被 TS 束缚住了手脚。
  • 如果是大型应用,且生命周期比较长,那建议试试 TS。
  • 如果是框架、库之类的公共模块,那更建议用 TS 了。

至于到底用不用TS,还是要看实际项目规模、项目生命周期、团队规模、团队成员情况等实际情况综合考虑。

其实本项目也是小项目来的,其实并不太适合加入 TypeScript ,不过这个项目是个人的项目,是为了练手用的,所以就无伤大大雅。

未来,class-compoent 也将成为主流,现在写 TypeScript 以后进行 3.0 的迁移会更加方便。

每天下班后,用几个晚上的时间来写这篇文章,码字不易,如果您觉得这篇文章不错或者对你有所帮助,请给个赞或者星吧,你的点赞就是我继续创作的最大动力。

参考文章:

  1. vue + typescript 项目起手式
  2. TypeScript + 大型项目实战

全栈修炼 有兴趣的朋友可以扫下方二维码关注我的公众号

我会不定期更新有价值的内容,长期运营。

关注公众号并回复 福利 可领取免费学习资料,福利详情请猛戳: Python、Java、Linux、Go、node、vue、react、javaScript

全栈修炼

查看原文

赞 117 收藏 84 评论 10

Near_Li 赞了文章 · 2020-12-23

Vant 3.0 正式发布:全面拥抱 Vue 3

Vant 是有赞前端团队开源的一套轻量、可靠的移动端组件库。

历经八个月时长的开发,Vant 3.0 终于和大家正式见面咯。在本次迭代中,我们的主要工作是基于 Vue 3 重构整个代码仓库和周边生态,并发布 Vant 3.0、Vant Cli 3.0 和 Vant Use 1.0。

回顾

按照惯例,我们先简单回顾一下 Vant 开源至今的成绩:

  • 270 位开发者 参与了 Vant 和 VantWeapp 开发,并贡献了 4100 个 Pull Request
  • 7300 个 issue 被关闭,99.3% 的 issue 得到解决或答复
  • 站点月访问量 800 万次
  • CDN 月下载量 3 亿次

更新内容

Vant 3.0:全面拥抱 Vue 3 💪

Vue 3 带来了许多激动人心的新特性,比如 Composition API、emits Option 和 Teleport。在 Vant 3.0 中,我们全面拥抱了 Vue 3 带来的各种变化,完成下列改造:

  • 使用 Composition API 重构所有组件
  • 使用 Composition API 重写所有文档和示例
  • 组件增加 emits 选项,提供更好的事件提示
  • 移除所有 mixins,提升代码可读性
  • 所有弹窗类组件支持 teleport 属性

重构完成后,组件之间可以基于 Composition API 进行逻辑复用,代码的可压缩性也有所提升。与 Vant 2.12 版本进行对比,可以看到 Vant 3.0 的 JS 体积下降了 16.6%,Gzip 后体积下降至 67.5kb。

新组件:Vant 2、Vant 3 同步供应

Vant 3.0 中包含 3 个全新的组件,分别是:

  • Badge 徽标:用于在右上角展示徽标数字或小红点。
  • Popover 气泡弹出框:弹出式的气泡菜单组件。
  • Cascader 级联选择器:用于多层级数据的选择,典型场景为省市区选择。

考虑到大部分开发者仍然在使用 Vue 2 进行开发,我们在 Vant 2 中同步实现了以上三个组件,大家可以升级到 Vant 2.12 版本进行使用。

Vant Use:新伙伴 👬

Vant Use 从 Vant 中沉淀出的 Composition API 库。除了提供常用的 Composition API 外,Vant Use 也会将某些组件的逻辑抽离出来,让开发者在使用组件逻辑的同时,能够完全自定义组件的展现形式。

下面是一个简单的例子,我们将 CountDown 组件的倒计时逻辑抽象为 useCountDown 方法,功能与 CountDown 组件基本等价,但使用起来更加灵活,我们可以自定义倒计时的 UI 样式,或者通过 computed 对倒计时进行预处理。

Vant Use 仍然处于早期阶段,在未来的演进过程中,我们会继续抽离 Vant 组件内部的通用逻辑,并下沉到 Vant Use 中。

Vant Cli 3.0:更新,更快 🚀

Vant Cli 是 Vant 底层的组件库构建工具,通过 Vant Cli 可以快速搭建一套功能完备的 Vue 组件库。在 Vant Cli 3.0 中,我们对所有底层依赖进行了大版本升级,在支持 Vue 3 的同时,提供更流畅的开发体验。

  • 升级 Vue 3、VueRouter 4、VueLoader 16
  • 升级 Webpack 5,开启持久缓存能力
  • 升级 Docsearch 3,全新的搜索框样式
  • 升级 TypeScript 4、ESLint 7

在创建 vant-cli 工程时,你可以自由选择基于 Vue 2 或者 Vue 3 进行组件库开发:

Vant Demo:2 个新的示例工程

Vant Demo 是 Vant 官方提供的示例工程合集,在本次迭代中,我们新增了 2 个示例工程,分别演示:

  • 如何使用 Vue 3 + Vant 3 + Vue Cli 搭建应用
  • 如何使用 Vue 3 + Vant 3 + Vite 搭建应用

许多喜欢尝鲜的小伙伴已经在使用 Vite 进行开发了,在使用 Vite 的过程中,经常令大家困惑的一点是,如何在 Vite 中进行按需引入 Vant 组件。在 Vue Cli 中,我们可以通过 babel-plugin-import 插件实现按需引入,但在 Vite 中无法使用该插件。

其实在 Vite 中无须考虑按需引入的问题。Vite 在构建代码时,会自动通过 Tree Shaking 移除未使用的 ESM 模块。而 Vant 3.0 内部所有模块都是基于 ESM 编写的,天然具备按需引入的能力。现阶段遗留的问题是,未使用的组件样式无法被 Tree Shaking 识别并移除,后续我们会考虑通过 Vite 插件的方式进行支持。

开始尝鲜

目前,Vant 官网默认展示 Vant 2 的 API 文档,你可以通过官网右上角的版本切换按钮访问 Vant 3 的文档,也可以 👉 点此访问

同时,Vant 的 npm latest 标签也保持在 v2 版本,这意味着使用 npm install vant 命令仍会安装 Vant 2,而安装 Vant 3 需要使用 npm install vant@next 命令。在 Vue 的默认文档版本和 npm 标签切换为 v3 后,我们也会同步进行切换。

从现有 Vant 2 项目升级,请参考 🚀 升级指南

下一步计划

未来 6 ~ 12 个月内,我们会保持 Vant 2 和 Vant 3 的功能同步更新。随着 Vue 3 的普及,我们会逐步降低 Vant 2 的维护频率,并将工作重心转移到 Vant 3 上。

另外,除了官方维护的 Vue 版本和微信小程序版本,Vant 也有由社区的小伙伴们发起和维护的 React 版本支付宝小程序版本,欢迎大家一起参与建设 💪

不平凡的 2020 年即将过去,希望 Vant 能给大家的工作带来一点点的帮助,我们明年再会。

查看原文

赞 16 收藏 5 评论 3

Near_Li 关注了专栏 · 2020-12-22

AlloyTeam 技术专栏

探索前端技术

关注 3766

Near_Li 收藏了文章 · 2020-12-21

「 实用推荐」如何时时判断元素是否进入当前视区

背景

在上篇文章: 记一次 「 无限列表 」滚动优化 中,

我介绍了「 如何优化一个无限滚动列表 」。

用到了懒加载方案, 一个关键点是:需要判断元素是否在当前视区

我们今天就看看这个问题。

今天的主要内容包括:

  • 使用元素位置判断元素是否在当前视区
  • 使用 Intersection Observer 判断元素是否在当前视区
  • 实例:懒加载
  • 实例:无限滚动
  • 实用 npm 包推荐

正文

1. 使用元素位置判断元素是否在当前视区

这种方法实现起来比较简单, 我们一步一步来。

首先:编写一个 util 函数 isVisible,它将仅接收一个参数,即 element。

export const isVisible = (el) => { };

使用 getBoundingClientRect 获取该元素的位置

const rect = el.getBoundingClientRect();

将找到窗口的高度和宽度

const vWidth = window.innerWidth || document.documentElement.clientWidth;

const vHeight = window.innerHeight || document.documentElement.clientHeight;

再编写一个函数,该函数基本上将接收 xy 点,并使用elementFromPoint函数返回元素。

const elementFromPoint = function (x, y) { 
  return document.elementFromPoint(x, y); 
};

检查元素是否在窗口内:

// Return false if it's not in the viewport
if (rect.right < 0 
  || rect.bottom < 0
  || rect.left > vWidth 
  || rect.top > vHeight) { 
  return false; 
}

边界检查:

// Return true if any of its four corners are visible
 return (
   el.contains(elementFromPoint(rect.left, rect.top))
   || el.contains(efp(rect.right, rect.top))
   || el.contains(efp(rect.right, rect.bottom))
   || el.contains(efp(rect.left, rect.bottom))
 );

完整代码:

export const isVisible = (el) => {
  const rect = el.getBoundingClientRect();
  const vWidth = window.innerWidth || document.documentElement.clientWidth;
  const vHeight = window.innerHeight || document.documentElement.clientHeight;
  const efp = function (x, y) { return document.elementFromPoint(x, y); };

  // Return false if it's not in the viewport
  if (rect.right < 0 || rect.bottom < 0
            || rect.left > vWidth || rect.top > vHeight) { return false; }

  // Return true if any of its four corners are visible
  return (
    el.contains(
      elementFromPoint(rect.left, rect.top))
      || el.contains(efp(rect.right, rect.top))
      || el.contains(efp(rect.right, rect.bottom))
      || el.contains(efp(rect.left, rect.bottom))
  );
};

用法:

import { isVisible } from '../utils';
// ...
const ele = document.getElementById(id);
return isVisible(ele);

逻辑并不复杂,不过多介绍。

2. 使用 Intersection Observer 判断元素是否在当前视区

Intersection Observer 是一种更高效的方式。

为什么这么说呢?

比如说,你想跟踪 DOM 树里的一个元素,当它进入可见窗口时得到通知。

可以通过绑定 scroll 事件或者用一个定时器,然后再回调函数中调用元素的 getBoundingClientRect 获取元素位置实现这个功能。

但是,这种实现方式性能极差

因为每次调用 getBoundingClientRect 都会强制浏览器重新计算整个页面的布局,可能给你的网站造成相当大的闪烁。

如果你的站点被加载到一个 iframe 里,而你想要知道用户什么时候能看到某个元素,这几乎是不可能的。

单原模型(Single Origin Model)和浏览器不会让你获取 iframe 里的任何数据。

这对于经常在 iframe 里加载的广告页面来说是一个很常见的问题。

IntersectionObserver 就是为此而生的。

它让检测一个元素是否可见更加高效

IntersectionObserver 能让你知道一个被观测的元素什么时候进入或离开浏览器的可见窗口。

2333.gif

使用 IntersectionObserver 也非常简单, 两步走:

  1. 创建 IntersectionObserver
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    // ...
    console.log(entry);
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
}, options);
  1. 将元素传递给 IntersectionObserver
const element = document.querySelector('.element');
observer.observe(element);

entries 参数会被传递给你的回调函数,它是一个 IntersectionObserverEntry 对象数组。

每个对象都包含更新过的交点数据针对你所观测的元素之一。

从输出最有用的特性是:

  • isIntersecting
  • target
  • intersectionRect

isIntersecting:当元素与默认根(在本例中为视口)相交时,将为true.

target:这将是我们将要观察的页面上的实际元素

intersectionRect: intersectionRect 告诉元素的可见部分。这将包含有关元素,其高度,宽度,视口位置等的信息。

在线 Demo:
https://codepen.io/myogeshcha...

更多有用的属性

现在我们知道: 当被观测的元素部分进入可见窗口时会触发回调函数一次,当它离开可见窗口时会触发另一次。

这样就回答了一个问题:元素 X 在不在可见窗口里。

但在某些场合,仅仅如此还不够。

这时候就轮到 threshold 登场了。

它允许你定义一个 intersectionRatio 临界值。

每次 intersectionRatio 经过这些值的时候,你的回调函数都会被调用。

threshold 的默认值是[0],就是默认行为。

如果我们把 threshold 改为[0, 0.25, 0.5, 0.75, 1],当元素的每四分之一变为可见时,我们都会收到通知:

2444.gif

还一个属性没在上文列出: rootMargin .

rootMargin 允许你指定到跟元素的距离,允许你有效的扩大或缩小交叉区域面积。

这些 margin 使用 CSS 风格的字符串,例如: 10px 20px 30px 40px,依次指定上、右、下、左边距。

new IntersectionObserver(entries => {
  // do something with entries
}, {
  // options
  // 用于计算相交区域的根元素
  // 如果未提供,使用最上级文档的可见窗口
  root: null,
  // 同 margin,可以是 1、2、3、4 个值,允许时负值。
  // 如果显式指定了跟元素,该值可以使用百分比,即根元素大小的百分之多少。
  // 如果没指定根元素,使用百分比会出错。
  rootMargin: "0px",
  // 触发回调函数的临界值,用 0 ~ 1 的比率指定,也可以是一个数组。
  // 其值是被观测元素可视面积 / 总面积。
  // 当可视比率经过这个值的时候,回调函数就会被调用。
  threshold: [0],
});

有一点要注意:IntersectionObserver 不是完美精确到像素级别,也不是低延时性的。

使用它实现类似依赖滚动效果的动画注定会失败。

因为回调函数被调用的时候那些数据——严格来说已经过期了。

3. 实例:懒加载(lazy load)

有时,我们希望某些静态资源(比如图片),只有用户向下滚动,它们进入视口时才加载,这样可以节省带宽,提高网页性能。这就叫做"惰性加载"。

有了 IntersectionObserver API,实现起来就很容易了。

function query(selector) {
  return Array.from(document.querySelectorAll(selector));
}

const observer = new IntersectionObserver(
  function(changes) {
    changes.forEach(function(change) {
      var container = change.target;
      var content = container.querySelector('template').content;
      container.appendChild(content);
      observer.unobserve(container);
    });
  }
);

query('.lazy-loaded').forEach(function (item) {
  observer.observe(item);
});

上面代码中,只有目标区域可见时,才会将模板内容插入真实 DOM,从而引发静态资源的加载。

4. 实例:无限滚动

无限滚动(infinite scroll)的实现也很简单:

const intersectionObserver = new IntersectionObserver(
  function (entries) {
    // 如果不可见,就返回
    if (entries[0].intersectionRatio <= 0) return;
    loadItems(10);
    console.log('Loaded new items');
  });

// 开始观察
intersectionObserver.observe(
  document.querySelector('.scrollerFooter')
);

无限滚动时,最好在页面底部有一个页尾栏。

一旦页尾栏可见,就表示用户到达了页面底部,从而加载新的条目放在页尾栏前面。

这样做的好处是:

不需要再一次调用 observe() 方法, 现有的 IntersectionObserver 可以保持使用。

5. 实用 Npm 包推荐

和今天话题相关的npm 包推荐的是:react-visibility-sensor

地址: https://www.npmjs.com/package...

用法也很简答:

import VisibilitySensor from "react-visibility-sensor";
 
function onChange (isVisible) {
  console.log('Element is now %s', isVisible ? 'visible' : 'hidden');
}
 
function MyComponent (props) {
  return (
    <VisibilitySensor onChange={onChange}>
      <div>...content goes here...</div>
    </VisibilitySensor>
  );
}

动态效果演示:

在线demo :
https://codesandbox.io/s/p73k...:174-229

结尾

内容大概就这么多, 希望对大家有所启发。

我的博客即将同步至 OSCHINA 社区,这是我的 OSCHINA ID:sskk2,邀请大家一同入驻:https://www.oschina.net/shari...

关注我

如果你觉得这篇内容对你挺有启发,那就关注我吧~

图片

更多精彩:

聊聊 ESM、Bundleless 、Vite 、Snowpack

记一次 「 无限列表 」滚动优化

「 面试三板斧 」之 代码分割(上)

「 面试三板斧 」之缓存 (上)

「 面试三板斧 」之缓存 (下)

「 面试三板斧 」之 HTTP (上)

「 面试三板斧 」之 HTTP (下)

「 面试三板斧 」之  this

参考文章

  1. https://developer.mozilla.org...
  2. https://www.webhek.com/post/i...
查看原文

Near_Li 赞了文章 · 2020-12-21

「 实用推荐」如何时时判断元素是否进入当前视区

背景

在上篇文章: 记一次 「 无限列表 」滚动优化 中,

我介绍了「 如何优化一个无限滚动列表 」。

用到了懒加载方案, 一个关键点是:需要判断元素是否在当前视区

我们今天就看看这个问题。

今天的主要内容包括:

  • 使用元素位置判断元素是否在当前视区
  • 使用 Intersection Observer 判断元素是否在当前视区
  • 实例:懒加载
  • 实例:无限滚动
  • 实用 npm 包推荐

正文

1. 使用元素位置判断元素是否在当前视区

这种方法实现起来比较简单, 我们一步一步来。

首先:编写一个 util 函数 isVisible,它将仅接收一个参数,即 element。

export const isVisible = (el) => { };

使用 getBoundingClientRect 获取该元素的位置

const rect = el.getBoundingClientRect();

将找到窗口的高度和宽度

const vWidth = window.innerWidth || document.documentElement.clientWidth;

const vHeight = window.innerHeight || document.documentElement.clientHeight;

再编写一个函数,该函数基本上将接收 xy 点,并使用elementFromPoint函数返回元素。

const elementFromPoint = function (x, y) { 
  return document.elementFromPoint(x, y); 
};

检查元素是否在窗口内:

// Return false if it's not in the viewport
if (rect.right < 0 
  || rect.bottom < 0
  || rect.left > vWidth 
  || rect.top > vHeight) { 
  return false; 
}

边界检查:

// Return true if any of its four corners are visible
 return (
   el.contains(elementFromPoint(rect.left, rect.top))
   || el.contains(efp(rect.right, rect.top))
   || el.contains(efp(rect.right, rect.bottom))
   || el.contains(efp(rect.left, rect.bottom))
 );

完整代码:

export const isVisible = (el) => {
  const rect = el.getBoundingClientRect();
  const vWidth = window.innerWidth || document.documentElement.clientWidth;
  const vHeight = window.innerHeight || document.documentElement.clientHeight;
  const efp = function (x, y) { return document.elementFromPoint(x, y); };

  // Return false if it's not in the viewport
  if (rect.right < 0 || rect.bottom < 0
            || rect.left > vWidth || rect.top > vHeight) { return false; }

  // Return true if any of its four corners are visible
  return (
    el.contains(
      elementFromPoint(rect.left, rect.top))
      || el.contains(efp(rect.right, rect.top))
      || el.contains(efp(rect.right, rect.bottom))
      || el.contains(efp(rect.left, rect.bottom))
  );
};

用法:

import { isVisible } from '../utils';
// ...
const ele = document.getElementById(id);
return isVisible(ele);

逻辑并不复杂,不过多介绍。

2. 使用 Intersection Observer 判断元素是否在当前视区

Intersection Observer 是一种更高效的方式。

为什么这么说呢?

比如说,你想跟踪 DOM 树里的一个元素,当它进入可见窗口时得到通知。

可以通过绑定 scroll 事件或者用一个定时器,然后再回调函数中调用元素的 getBoundingClientRect 获取元素位置实现这个功能。

但是,这种实现方式性能极差

因为每次调用 getBoundingClientRect 都会强制浏览器重新计算整个页面的布局,可能给你的网站造成相当大的闪烁。

如果你的站点被加载到一个 iframe 里,而你想要知道用户什么时候能看到某个元素,这几乎是不可能的。

单原模型(Single Origin Model)和浏览器不会让你获取 iframe 里的任何数据。

这对于经常在 iframe 里加载的广告页面来说是一个很常见的问题。

IntersectionObserver 就是为此而生的。

它让检测一个元素是否可见更加高效

IntersectionObserver 能让你知道一个被观测的元素什么时候进入或离开浏览器的可见窗口。

2333.gif

使用 IntersectionObserver 也非常简单, 两步走:

  1. 创建 IntersectionObserver
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    // ...
    console.log(entry);
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
}, options);
  1. 将元素传递给 IntersectionObserver
const element = document.querySelector('.element');
observer.observe(element);

entries 参数会被传递给你的回调函数,它是一个 IntersectionObserverEntry 对象数组。

每个对象都包含更新过的交点数据针对你所观测的元素之一。

从输出最有用的特性是:

  • isIntersecting
  • target
  • intersectionRect

isIntersecting:当元素与默认根(在本例中为视口)相交时,将为true.

target:这将是我们将要观察的页面上的实际元素

intersectionRect: intersectionRect 告诉元素的可见部分。这将包含有关元素,其高度,宽度,视口位置等的信息。

在线 Demo:
https://codepen.io/myogeshcha...

更多有用的属性

现在我们知道: 当被观测的元素部分进入可见窗口时会触发回调函数一次,当它离开可见窗口时会触发另一次。

这样就回答了一个问题:元素 X 在不在可见窗口里。

但在某些场合,仅仅如此还不够。

这时候就轮到 threshold 登场了。

它允许你定义一个 intersectionRatio 临界值。

每次 intersectionRatio 经过这些值的时候,你的回调函数都会被调用。

threshold 的默认值是[0],就是默认行为。

如果我们把 threshold 改为[0, 0.25, 0.5, 0.75, 1],当元素的每四分之一变为可见时,我们都会收到通知:

2444.gif

还一个属性没在上文列出: rootMargin .

rootMargin 允许你指定到跟元素的距离,允许你有效的扩大或缩小交叉区域面积。

这些 margin 使用 CSS 风格的字符串,例如: 10px 20px 30px 40px,依次指定上、右、下、左边距。

new IntersectionObserver(entries => {
  // do something with entries
}, {
  // options
  // 用于计算相交区域的根元素
  // 如果未提供,使用最上级文档的可见窗口
  root: null,
  // 同 margin,可以是 1、2、3、4 个值,允许时负值。
  // 如果显式指定了跟元素,该值可以使用百分比,即根元素大小的百分之多少。
  // 如果没指定根元素,使用百分比会出错。
  rootMargin: "0px",
  // 触发回调函数的临界值,用 0 ~ 1 的比率指定,也可以是一个数组。
  // 其值是被观测元素可视面积 / 总面积。
  // 当可视比率经过这个值的时候,回调函数就会被调用。
  threshold: [0],
});

有一点要注意:IntersectionObserver 不是完美精确到像素级别,也不是低延时性的。

使用它实现类似依赖滚动效果的动画注定会失败。

因为回调函数被调用的时候那些数据——严格来说已经过期了。

3. 实例:懒加载(lazy load)

有时,我们希望某些静态资源(比如图片),只有用户向下滚动,它们进入视口时才加载,这样可以节省带宽,提高网页性能。这就叫做"惰性加载"。

有了 IntersectionObserver API,实现起来就很容易了。

function query(selector) {
  return Array.from(document.querySelectorAll(selector));
}

const observer = new IntersectionObserver(
  function(changes) {
    changes.forEach(function(change) {
      var container = change.target;
      var content = container.querySelector('template').content;
      container.appendChild(content);
      observer.unobserve(container);
    });
  }
);

query('.lazy-loaded').forEach(function (item) {
  observer.observe(item);
});

上面代码中,只有目标区域可见时,才会将模板内容插入真实 DOM,从而引发静态资源的加载。

4. 实例:无限滚动

无限滚动(infinite scroll)的实现也很简单:

const intersectionObserver = new IntersectionObserver(
  function (entries) {
    // 如果不可见,就返回
    if (entries[0].intersectionRatio <= 0) return;
    loadItems(10);
    console.log('Loaded new items');
  });

// 开始观察
intersectionObserver.observe(
  document.querySelector('.scrollerFooter')
);

无限滚动时,最好在页面底部有一个页尾栏。

一旦页尾栏可见,就表示用户到达了页面底部,从而加载新的条目放在页尾栏前面。

这样做的好处是:

不需要再一次调用 observe() 方法, 现有的 IntersectionObserver 可以保持使用。

5. 实用 Npm 包推荐

和今天话题相关的npm 包推荐的是:react-visibility-sensor

地址: https://www.npmjs.com/package...

用法也很简答:

import VisibilitySensor from "react-visibility-sensor";
 
function onChange (isVisible) {
  console.log('Element is now %s', isVisible ? 'visible' : 'hidden');
}
 
function MyComponent (props) {
  return (
    <VisibilitySensor onChange={onChange}>
      <div>...content goes here...</div>
    </VisibilitySensor>
  );
}

动态效果演示:

在线demo :
https://codesandbox.io/s/p73k...:174-229

结尾

内容大概就这么多, 希望对大家有所启发。

我的博客即将同步至 OSCHINA 社区,这是我的 OSCHINA ID:sskk2,邀请大家一同入驻:https://www.oschina.net/shari...

关注我

如果你觉得这篇内容对你挺有启发,那就关注我吧~

图片

更多精彩:

聊聊 ESM、Bundleless 、Vite 、Snowpack

记一次 「 无限列表 」滚动优化

「 面试三板斧 」之 代码分割(上)

「 面试三板斧 」之缓存 (上)

「 面试三板斧 」之缓存 (下)

「 面试三板斧 」之 HTTP (上)

「 面试三板斧 」之 HTTP (下)

「 面试三板斧 」之  this

参考文章

  1. https://developer.mozilla.org...
  2. https://www.webhek.com/post/i...
查看原文

赞 19 收藏 15 评论 1

Near_Li 收藏了文章 · 2020-11-19

文本内容超过N行折叠并显示“...查看全部”

本文发布于我的个人网站:https://wintc.top/article/58,转载请注明。

多行文本超过指定行数隐藏超出部分并显示“...查看全部”是一个常遇到的需求,网上也有人实现过类似的功能,不过还是想自己写写看,于是就写了一个Vue的组件,本文简单介绍一下实现思路。

遇到这个需求的同学可以尝试一下这个组件,支持npm安装使用:

组件地址:https://github.com/Lushenggang/vue-overflow-ellipsis
在线体验:https://wintc.top/laboratory/#/ellipsis

一、需求描述

        长度不定的一段文字,最多显示n行(比如3行),不超过n行正常显示;超过n行则在最后一行尾部显示“展开”或“查看全部”之类的按钮,点击按钮则展开显示全部内容,或者跳转到其它页面展示所有内容。

        预期效果如下:

多行文本超过指定行数折叠

二、实现原理

        纯CSS很难完美实现这个功能,所以还得借助JS来实现,实现思路大体相似,都是判断内容是否超过指定行数,超过则截取字符串的前x个字符,然后然后和“...查看全部”拼接在一起,这里的x即截取长度,需要动态计算。

        想通过上述方案实现,有几个问题需要解决:

    • 怎样判断文字是否超过指定行数
    • 如何计算字符串截取长度
    • 动态响应,包括响应页面布局变动、字符串变化、指定行数变化等

        下面具体研究一下这些问题。

1. 怎样判断一段文字是否超过指定行数?

        首先解决一个小问题:如何计算指定行数的高度?我首先想到的是使用textarea的rows属性,指定行数,然后计算textarea撑起的高度。另一个方法是将行高的计算值与行数相乘,即得到指定行数的高度,这个办法我没尝试过,但是想必可行。

        解决了指定行数高度的问题,计算一段文字是否超过指定行数就很容易了。我们可以将指定行数的textarea使用绝对定位absolute脱离文档流,放到文字的下方,然后通过文本容器的底部与textarea的底部相比较,如果文本容器的底部更靠下,说明超过指定行数。这个判断可以通过getBoundingClientRect接口获取到两个容器的位置、大小信息,然后比较位置信息中的bottom属性即可。

        可以这样设计DOM结构:

 <div class="ellipsis-container">
    <div class="textarea-container">
      <textarea rows="3" readonly tabindex="-1"></textarea>
    </div>
    {{ showContent }} <-- showContent表示字符串截取部分 --> 
    ... 查看更多
  </div>

        然后使用CSS控制textarea,使其脱离文档流并且不能被看到以及被触发鼠标事件等(textarea标签中的readonly以及tabIndex属性是必要的):

.ellipsis-container
  text-align left
  position relative
  line-height 1.5
  padding 0 !important
  .textarea-container
    position absolute
    left 0
    right 0
    pointer-events none
    opacity 0
    z-index -1
    textarea
      vertical-align middle
      padding 0
      resize none
      overflow hidden
      font-size inherit
      line-height inherit
      outline none
      border none

2.如何计算字符串截取长度x——双边逼近法(二分思想)

        只要可以判断一段文字是否超过指定行数,那我们就可以动态地尝试截取字符串,直到找到合适的截断长度x。这个长度满足从x的位置截断字符串,前半部分+“...查看全部”等文字刚好不会超出指定行数N,但是多截取一个字,则会超出N行。最直观的想法就是直接遍历,让x从0开始增长到显示文本总长度,对于每个x值,都计算一次文字是否超过N行,没超过则加继续遍历,超过则获得了合适的长度x - 1,跳出循环。当然也可以让x从文本总长度递减遍历。

        不过这里最大的问题在于浏览器的回流和重绘。因为我们每次截取字符串都需要浏览器重新渲染出来才能得到是否超过N行,这过程中就触发了浏览器的重绘或回流,每次循环都会触发一次。而对于正常的需求来说,假设N取值是3,那很可能每次计算会导致50次以上的重绘或回流,这中间消耗的性能还是非常大的,不小心可能就是几十毫秒甚至上百毫秒。这个计算过程应该在一个任务(即常说的”宏任务“)中完成,否则计算过程中会出现显示闪动的”异常“情况,所以可以说计算过程是阻塞的,因此计算的总时间一定要控制到非常低,即要减少计算的次数。

        可以考虑使用"双边逼近法"(或称”二分法“)查找合适的截取长度x,大大减少尝试的次数。第一次先以文本长度为截取长度,计算是否超过N行,没超过则停止计算;超过则取1/2长度进行截取,如果此时没超过N行,则在1/2长度到文本长度之间继续二分查找,如果超过则在0到1/2文本长度中继续二分查找。直到查找区间开始值与结束值相差为1,则开始值即为所求。具体实现可以看下文中的完整代码。

3.监听页面变动

        对于Vue项目来说,传入组件的字符串、行数等可能随时改变,可以watch这些属性变化,然后重新计算一次截取长度。另一方面,对于页面布局而言,可能会因为其它页面元素的增删或者样式改变,导致页面布局变动,影响到文本容器的宽度,此时也应该重新计算一次截取长度。

        监听文本容器宽度的变化,可以考虑使用ResizeObserver来监听,但是这个接口的兼容性不够好(IE各个版本都不支持),因此选择了一个npm库element-resize-detector来监测(非常好用👍)。

三、代码实现

        完整的代码实现如下:

<template>
  <div class="ellipsis-container">
    <div class="textarea-container" ref="shadow">
      <textarea :rows="rows" readonly tabindex="-1"></textarea>
    </div>
    {{ showContent }}
    <slot name="ellipsis" v-if="(textLength < content.length) || btnShow">
      {{ ellipsisText }}
      <span class="ellipsis-btn" @click="clickBtn">{{ btnText }}</span>
    </slot>
  </div>
</template>

<script> import resizeObserver from 'element-resize-detector'
const observer = resizeObserver()

export default {
  props: {
    content: {
      type: String,
      default: ''
    },
    btnText: {
      type: String,
      default: '展开'
    },
    ellipsisText: {
      type: String,
      default: '...'
    },
    rows: {
      type: Number,
      default: 6
    },
    btnShow: {
      type: Boolean,
      default: false
    },
  },
  data () {
    return {
      textLength: 0,
      beforeRefresh: null
    }
  },
  computed: {
    showContent () {
      const length = this.beforeRefresh ? this.content.length : this.textLength
      return this.content.substr(0, this.textLength)
    },
    watchData () { // 用一个计算属性来统一观察需要关注的属性变化
      return [this.content, this.btnText, this.ellipsisText, this.rows, this.btnShow]
    }
  },
  watch: {
    watchData: {
      immediate: true,
      handler () {
        this.refresh()
      }
    },
  },
  mounted () {
    // 监听尺寸变化
    observer.listenTo(this.$refs.shadow, () => this.refresh())
  },
  beforeDestroy () {
    observer.uninstall(this.$refs.shadow)
  },
  methods: {
    refresh () { // 计算截取长度,存储于textLength中
      this.beforeRefresh && this.beforeRefresh()
      let stopLoop = false
      this.beforeRefresh = () => stopLoop = true
      this.textLength = this.content.length
      const checkLoop = (start, end) => {
        if (stopLoop || start + 1 >= end) return
        const rect = this.$el.getBoundingClientRect()
        const shadowRect = this.$refs.shadow.getBoundingClientRect()
        const overflow = rect.bottom > shadowRect.bottom
        overflow ? (end = this.textLength) : (start = this.textLength)
        this.textLength = Math.floor((start + end) / 2)
        this.$nextTick(() => checkLoop(start, end))
      }
      this.$nextTick(() => checkLoop(0, this.textLength))
    },
    // 展开按钮点击事件向外部emit
    clickBtn (event) {
      this.$emit('click-btn', event)
    },
  }
} </script>

        在代码实现中refresh函数用于计算截取长度,在文本内容、rows属性等发生改变或者文本容器尺寸改变时将被调用。每次refresh调用会异步地递归调用多次checkLoop,refresh可能重新调用,新的refresh调用将结束之前的checkLoop的调用。

四、其它

1. 支持HTML串的考虑

        现在的实现方案并不支持内容是HTML文本,如果需要支持HTML文本,问题将复杂许多。主要在于HTML字符串的解析和截断,不像文本字字符串那么简单。不过或许可以借助浏览器的Range API 来实现截断位置的定位,Range的insertNode以及setStart接口可以将“...查看全部”插入到指定位置,而如果插入位置刚好符合需要,则可以通过Range.cloneContents()")接口取得截取HTML字符串的相关内容,理论上是可行的,不过具体细节以及处理效率得实践后才知道。

2. 减少浏览器回流的影响

        上述实现方案中,每一次截取都需要浏览器重新渲染DOM,即重绘。重绘的影响还比较小,而如果截取的字符串行数发生改变,还会引发文本容器的高度变化,这时候就会导致浏览器回流,而文本容器在文档流中,回流将会影响整个文档。

        想解决这个问题,可以使用一个脱离文档流的元素来进行字符串动态截断后的渲染与判断,布局就类似上述的textarea。因为不在文档流中,回流的影响范围就会减少到该元素自身。获得截断长度后再截断文本,渲染到真正的文本容器即可。本文仅作为一个简单的原理概述的示例,没有做这个处理,对具体细节感兴趣的同学,可以查看github仓库代码。

查看原文

Near_Li 赞了文章 · 2020-10-26

从 Egg.js 到 NestJS,爱码客后端选型之路

爱码客3.0 开始开发到现在已经过去快整整一年了,虽然我投入其中的时间只有短短4个月,但是在最初后端几乎只有我一个人投入的情况下,可以说也是研究了一些东西,蹚了二三次浑水,来来回回改过五六次结构,心里七上八下的时间也不少,当然最后折腾出来的东西肯定到不了九十分。但,这些都不重要了,事了拂衣去,深藏功(辛)与名(酸)。如今回头,只是把当时一些探索的历程简单记录一下,权当给这段经历画下一个省略号。。。

青梅竹马

爱码客是一个 Node 应用,在当时的阿里经济体里,提到 Node 应用的框架,Egg.js 可谓无人不知,无人不晓。作为阿里声名在外的一个重要开源产品,这几年它在集团内也是独占鳌头的一个态势。故而,Egg.js 当然是我们第一眼的选择。并且之前在 图灵计划 和 UTT 中我都与它并肩作战,现在再次相遇,那必然是驾轻就熟,三下五除二便能把一整个框架给建立起来。于是说干就干,立马根据 Egg.js 的规范,整理了一个代码框架进行了第一次汇报。

主管之命,媒妁之言

第一次汇报,主管自然是欲扬先抑,于是在主管的耳提面命之下,我总结出了两个需要改进的点,并且知道了主管最终想要的是什么:一个标准化,但是高度可扩展的服务框架。最终的想法且先不提,让我们先看看这两个痛点是什么。

第一点,Egg.js 是一个约定大于配置的框架

Egg 奉行『约定优于配置』,按照一套统一的约定进行应用开发,团队内部采用这种方式可以减少开发人员的学习成本,开发人员不再是『钉子』,可以流动起来。

正因为如此,Egg.js 中对于目录的规范是有一个约束的,一个基础的 Egg.js 项目的目录结构如下:

egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
|   ├── router.js
│   ├── controller
│   |   └── home.js
│   ├── service (可选)
│   |   └── user.js
│   ├── middleware (可选)
│   |   └── response_time.js
│   ├── schedule (可选)
│   |   └── my_task.js
│   ├── public (可选)
│   |   └── reset.css
│   ├── view (可选)
│   |   └── home.tpl
│   └── extend (可选)
│       ├── helper.js (可选)
│       ├── request.js (可选)
│       ├── response.js (可选)
│       ├── context.js (可选)
│       ├── application.js (可选)
│       └── agent.js (可选)
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js (可选)
|   ├── config.local.js (可选)
|   └── config.unittest.js (可选)
└── test
    ├── middleware
    |   └── response_time.test.js
    └── controller
        └── home.test.js

大家可以看到,在我们的代码目录 app 中,所有的代码文件是按照功能来归类的,比如所有的控制器代码都会放置在同一个目录下,所有的服务代码也全部放置在 service 目录下。诚然,这是一个合理的分类方式。但有时候对于一些开发团队来说,在模块众多的情况下,开发时需要来回切换分散在不同目录下的文件,给开发带来了不便,并且相同模块的代码的分散也会导致阅读项目的障碍。那么,我们能不能够让 Egg.js 支持像下面一样按模块来归类的目录结构呢?

egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── src
│   ├── router.js
│   ├── home
│   │   ├── home.controller.ts
│   │   ├── home.service.ts
│   │   └── home.tpl
│   └── user
│       ├── user.controller.ts
│       └── user.service.ts
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js (可选)
|   ├── config.local.js (可选)
|   └── config.unittest.js (可选)
---

经过对 Egg.js 文档和 egg-core 源码的一番研究,发现它提供了扩展 Loader 的方式来自定义加载目录的行为,但是由于以下的约束,所以我们要自定义 loader 的话,必须基于 Egg 建立一个新的框架,然后再基于这个框架进行开发。

Egg 基于 Loader 实现了 AppWorkerLoader 和 AgentWorkerLoader,上层框架基于这两个类来扩展,Loader 的扩展只能在框架进行

因此,我们需要做的事情大概是:

  1. 使用 npm init egg --type=framework  建立一个框架
  2. lib/loader 中编写自己的 loader
  3. 在我们的项目中指定 egg 的 framework 为该框架即可
'use strict';
const fs = require('fs');
const path = require('path');
const egg = require('egg');
const extend = require('extend2');

class AiMakeAppWorkerLoader extends egg.AppWorkerLoader {
  constructor(opt) {
    super(opt);
    this.opt = opt;
  }

  loadControllers() {
    super.loadController({
      directory: [ path.join(this.opt.baseDir, 'src/') ],
      match: '**/*.controller.(js|ts)',
      caseStyle: filepath => {
        return customCamelize(filepath, '.controller');
      },
    });
  }

  loadServices() {
    super.loadService({
      directory: [ path.join(this.opt.baseDir, 'src/') ],
      match: '**/*.service.(js|ts)',
      caseStyle: filepath => {
        return customCamelize(filepath, '.service');
      },
    });
  }


  load() {
    this.loadApplicationExtend();
    this.loadRequestExtend();
    this.loadResponseExtend();
    this.loadContextExtend();
    this.loadHelperExtend();

    this.loadCustomLoader();

    // app > plugin
    this.loadCustomApp();
    // app > plugin
    this.loadServices();
    // app > plugin > core
    this.loadMiddleware();
    // app
    this.loadControllers();
    // app
    this.loadRouter(); // Dependent on controllers
  }
}

//...略过工具函数代码

module.exports = AiMakeAppWorkerLoader;

到此,我们攻破了第一个痛点。第二点,Egg.js 是一个基于 JavaScript 开发的框架,但现在时间已经到了 2019 年,TypeScript 作为 JavaScript 的一个超集,能够给我们带来强类型系统的各种优势,并且提供了更完善的面向对象编程的实现。我们在开发一个通用的服务框架时,没有理由不选择 TypeScript。然而 Egg.js 却没有原生提供 TypeScript 的支持,这里面可能有其历史原因,但对于我们来说是不可接受的。于是,在一番搜索之后,根据 这个 Issue 中的思路,我又一次找到了在 Egg.js 中使用 TypeScript 的方法。具体的步骤在链接里已经很详细了,其实主要就是两点:

  1. 在初始化 egg 项目时,加上 --type=ts 参数
  2. 开发时使用 egg-ts-helper 来帮助自动生成 d.ts 文件

这样下来就能比较愉快地使用 TypeScript 来编写 Egg.js 的代码了。

终于,两个痛点被我基本上解决了,于是我开开心心,不知天高地厚地又跑去进行了第二次汇报。

钗头凤

第二次汇报可就没那么轻松了,主管对于我的思考深度进行了毁灭性的批判。更让我认识到了,采用自定义 loader 这种方式虽然能够解决我的表面问题,但是根本性的约束还是没有消失,并且这种方式毫无灵活性,用户不可能为了让我们服务框架适应自己的组织文件的习惯而动手去写一个新的基于 Egg.js 的框架。并且,Egg.js 对于 TypeScript 的支持天生残疾,即便是使用了 egg-ts-helper 能够写出 ts 代码,各种三方库的支持也不受控制,用户还是需要承担很大的风险。

没有办法了,Egg.js,相濡以沫,不如相忘于江湖。

满堂兮美人,忽独与余兮目成

“分手”后的我,在 github 上到处寻找合适的框架,虽然也找到了好些个备胎,但却总是没有让我眼前一亮的那个。正在焦虑纠结之时,一起讨论的北京团队的小伙伴提到了它,NestJS。在我仔细查看了它的 github 主页之后,顿时有种被钦定的感觉。嗯,没错,就是它了!

既然有了新欢,那肯定要给大伙介绍一下,让我们先听听它的自述:

Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。
在底层,Nest使用强大的 HTTP Server 框架,如 Express(默认)和 Fastify。Nest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。

注意到了吧,它可是一个原生支持 TypeScript 的框架,这意味着 NestJS 以及它生态圈中的所有插件,都必然会是 TypeScript 的,这一下子就解决了我的第二个问题。那第一个问题有解吗?别急,让我慢慢给你道来。

初看 NestJS ,我们大家可能都觉得面生的很,这很正常,对于我们 Vue 和 React 技术栈的人来说,NestJS 的思维方式确实不那么容易理解。但是假如你接触过 AngularJS,也许会有一些熟悉感。那要是你曾经是一个后端开发人员,熟练使用 Java 和 Spring 的话,可能就会跳起来大喊一声:这不就是个 Spring boot 吗!

你的直觉没错,NestJS 和 AngularJS,Spring 类似,都是基于控制反转(IoC = Inversion of Control)原则来设计的框架,并且都使用了依赖注入(DI = Dependency Injection)的方式来解决耦合的问题。

何为依赖注入呢?简单举个例子,假设我们有个类 Car,和一个类 Engine,我们如下组织代码:

// 引擎 
export class Engine {
  public cylinders = '引擎发动机1';
}

export class Car {
  public engine: Engine;
  public description = 'No DI';

  constructor() {
    this.engine = new Engine();
  }

  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders`;
  }
}

// 示例参考 https://juejin.im/post/6844903740953067534

此时我们的引擎是在 Car 的实例中自己初始化的。那么假如有一天引擎进行了升级,在构造器中新增了一个参数:

// 引擎  
export class Engine {
  public cylinders = '';
  constructor(_cylinders:string) {
    this.cylinders = _cylinders;
  }
}

那么使用该引擎的汽车,就必须修改 Car 类中的构造器代码来适配引擎的改变。这很不合理,因为对汽车来说,应该不在意引擎的实现细节。此时我们说 Car 类中依赖了 Engine。

那假如我们使用依赖注入的方式来实现 Car 类:

export class Engine {
  public cylinders = '引擎发动机1';
}

export class Car {
  public description = 'DI'; 

  // 通过构造函数注入Engine和Tires
  constructor(public engine: Engine) {}  

  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders`;
  }
}

此时 Car 类不再亲自创建 Engine ,只是接收并且消费一个 Engine 的实例。而 Engine 的实例是在实例化 Car 类时通过构造函数注入进去的。于是 Car 类和 Engine 类就解除了耦合。假如我们要升级 Engine 类,也只需要在 Car 的实例化语句中做出修改即可。

export class Engine {
  public cylinders = '';
  constructor(_cylinders:string) {
    this.cylinders = _cylinders;
  }
}

export class Car {
  public description = 'DI'; 

  // 通过构造函数注入Engine和Tires
  constructor(public engine: Engine) {}  

  drive() {
    return `${this.description} car with ` +
      `${this.engine.cylinders} cylinders`;
  }
}

main(){
    const car = new Car(new Engine('引擎启动机2'), new Tires1());
    car.drive();
}

这就是依赖注入。

当然,这只是一个最简单的例子,实际情况下,NestJS 中的类实例化过程是委派给 IoC 容器(即 NestJS 运行时系统)的。并不需要我们每次手动注入。

那么说了这么多,依赖注入和我们的第一个问题有关系吗?当然有!我们知道,为什么 Egg.js 需要规定目录结构,是因为在 egg-core 的 loader 代码中,对于 Controller,Service,Config 等的加载是由不同的 load 函数查找指定目录来完成的。因此如果在指定的地方没有找到,那么 Egg.js 就无法获取并将它们挂载到 ctx 下。而 NestJS 则不同,依赖是我们自行在容器中注册的,也就是说,NestJS 并不需要自行去按指定位置寻找依赖。我们只需要将所需执行的 Controller,Service 等注入到模块中,模块即可获取它们并且使用。

// app.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';

@Controller()
export class AppController {
  @Inject('appService')
  private readonly appService;

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}


// app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}


// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app/app.controller';
import { AppService } from './app/app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [{
    provide: 'appService',
    useClass: AppService,
  }],
})
export class AppModule {}

如上,我们可以看到,使用 @Injectable 修饰后的 Service,在我们注册之后,在 app.controller.ts 中使用的时候可以直接用 @Inject('appService')  来将 Service 实例注入到属性中。此时在使用时我们根本不用关心 app.service.ts 到底在哪里,目录可以随便组织,唯一的要求是在容器中完成注册。 有了依赖注入,我们可以在开发时灵活注入配置,并且由于脱离了依赖的耦合,可测试性也更强。

当然,NestJS 的优势并不仅仅只有这两点,作为 Node 端对标 Java Spring 的框架,它的设计理念和开发约束在规模较大的项目开发中能够起到很大的帮助。并且,它天生支持微服务,对于大规模的项目,后续扩展也会比较方便。结合以上的优势,我们最后毅然决然地选择了 NestJS。

皇天不负有心人,这次主管没有棒打鸳鸯,终于走完了这一条选型之路。

蓦然回首,那人却在灯火阑珊处

时间过去了许久,Egg.js 和 NestJS 之争也早就有了结果。爱码客也如火如荼开发了半年有余。一天傍晚,收到了 Midway 的邮件,Egg.js 终于完成了他的历史使命,Midway 接过了这条接力棒,成为了集团内的标准框架。回想起当时在调研时,也曾看过 Midway ,还专门请教过负责的大神。当然最后由于对它还不是很熟悉,再加上觉得集团内部还是 Egg.js 为主就没有选择。如今早已没有选型的重担,闲下来再研究了一下现在的 Midway。确实已经是一个跟得上时代的框架了。

原生支持 TypeScript 的 Midway,再也不用像 Egg.js 一样备受诟病。而兼容了 Egg.js 的众多插件,也让它在集团内各场景的开发中游刃有余。基于 DI 的设计,让它在架构上也脱胎换骨。更加激进的是,Midway 对于依赖采用了自动扫描的机制,连手动注册依赖的一步都可以省去,这比起 NestJS ,对我来说确实可以算个惊喜。

Midway 内部使用了自动扫描的机制,在应用初始化之前,会扫描所有的文件,包含装饰器的文件会 自动绑定 到容器。

如果使用 Midway 的话,可能我们当时的一些痛点可以迎刃而解,并且代码还精简了不少呢。此时的我不禁马后炮的想着。然而,既然历史让我选择了 NestJS ,还是从一而终吧。

// app/controller/user.ts
import { Context, controller, get, inject, provide } from '@ali/midway';

@provide()
@controller('/user')
export class UserController {

  @inject()
  ctx: Context;

  @inject('userService')
  service;

  @get('/:id')
  async getUser(): Promise<void> {
    const id: number = this.ctx.params.id;
    const user = await this.service.getUser({id});
    this.ctx.body = {success: true, message: 'OK', data: user};
  }
}


// service/user.ts
import { provide } from '@ali/midway';
import { IUserService, IUserOptions, IUserResult } from '../interface';

@provide('userService')
export class UserService implements IUserService {

  async getUser(options: IUserOptions): Promise<IUserResult> {
    return {
      id: options.id,
      username: 'mockedName',
      phone: '12345678901',
      email: 'xxx.xxx@xxx.com',
    };
  }
}

武陵人

离开发已过将近一年,前端的发展日新月异,技术的选择也没有绝对的对错。远离了 Node 大半年,早已不知魏晋。记录便只是记录,写给大家的是一个故事,写给我的是一个念想。作者语云:不足为外人道也

文章可随意转载,但请保留此原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。
查看原文

赞 6 收藏 2 评论 1

Near_Li 关注了专栏 · 2020-10-26

ES2049 Studio

阿里巴巴 - CRO 技术部 - 体验技术

关注 1950

认证与成就

  • 获得 25 次点赞
  • 获得 11 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-06-02
个人主页被 749 人浏览