暂时未有相关云产品技术能力~
Vue3 为开发者提供 ref和 reactive两个 API 来实现响应式数据,这也是我们使用 Vue3 开发项目中经常用到的两个 API。本文从入门角度和大家介绍这两个 API,如果有错误,欢迎一起讨论学习~本文代码基于 Vue3 setup 语法。在入门阶段,我们需要掌握的是「是什么」、「怎么用」、「有什么注意」,基本就差不多了。1. reactive API 如何使用?reactive方法用来创建响应式对象,它接收一个对象/数组参数,返回对象的响应式副本,当该对象的属性值发生变化,会自动更新使用该对象的地方。下面以分别以对象和数组作为参数演示:import { reactive } from 'vue' let reactiveObj = reactive({ name : 'Chris1993' }); let setReactiveObj = () => { reactiveObj.name = 'Hello Chris1993'; } let reactiveArr = reactive(['a', 'b', 'c', 'd']); let setReactiveArr = () => { reactiveArr[1] = 'Hello Chris1993'; }模版内容如下:<template> <h2>Vue3 reactive API Base</h2> <div> Object:{{reactiveObj.name}} <span @click="setReactiveObj">Update</span> </div> <div> Array:{{reactiveArr}} <span @click="setReactiveArr">Update</span> </div> </template> 此时页面展示如下:当我们分别点击 Update按钮后,可以看到数据变化后,视图上内容也一起更新了:2. ref API 如何使用?ref 的作用就是将一个原始数据类型(primitive data type)转换成一个带有响应式特性的数据类型,原始数据类型共有7个,分别是:String/ Number /BigInt /Boolean /Symbol /Null /Undefined。ref的值在 JS/TS 中读取和修改时,需要使用 .value获取,在模版中读取是,不需要使用 .value。下面以分别以字符串和对象作为参数演示:import { ref } from 'vue' let refValue = ref('Chris1993'); let setRefValue = () => { refValue.value = 'Hello Chris1993'; } let refObj = ref({ name : 'Chris1993' }); let setRefObj = () => { refObj.value.name = 'Hello Chris1993'; }模版内容如下:<template> <h2>Vue3 ref API Base</h2> <div> String:{{refValue}} <span @click="setRefValue">Update</span> </div> <div> Object:{{refObj.name}} <span @click="setRefObj">Update</span> </div> </template> 此时页面展示如下:当我们分别点击 Update按钮后,可以看到数据变化后,视图内容也一起更新了:3. reactive 可以用在深层对象或数组吗?答案是可以的,reactive是基于 ES2015 Proxy API 实现的,它的响应式是整个对象的所有嵌套层级。下面以分别以对象和数组作为参数演示:import { reactive } from 'vue' let reactiveDeepObj = reactive({ user: {name : 'Chris1993'} }); let setReactiveDeepObj = () => { reactiveDeepObj.user.name = 'Hello Chris1993'; } let reactiveDeepArr = reactive(['a', ['a1', 'a2', 'a3'], 'c', 'd']); let setReactiveDeepArr = () => { reactiveDeepArr[1][1] = 'Hello Chris1993'; }模版内容如下:<template> <h2>Vue3 reactive deep API Base</h2> <div> Object:{{reactiveDeepObj.user.name}} <span @click="setReactiveDeepObj">Update</span> </div> <div> Array:{{reactiveDeepArr}} <span @click="setReactiveDeepArr">Update</span> </div> </template> 此时页面展示如下:当我们分别点击 Update按钮后,可以看到数据变化后,视图上内容也一起更新了:4. reactive 返回值和源对象相等吗?答案是不相等的,因为reactive是基于 ES2015 Proxy API 实现的,返回结果是个 proxy 对象。测试代码:let reactiveSource = { name: 'Chris1993' }; let reactiveData = reactive(reactiveSource); console.log(reactiveSource === reactiveData); // false console.log(reactiveSource); // {name: 'Chris1993'} console.log(reactiveData); // Reactive<{name: 'Chris1993'}>5. TypeScript 如何写 ref 和 reactive 参数类型?在使用 TypeScript 写 ref / reactive 参数类型时,可以根据 ref / reactive 接口类型来实现具体的类型:function ref<T>(value: T): Ref<T> function reactive<T extends object>(target: T): UnwrapNestedRefs<T>将前面实例代码改造一下:import { ref } from 'vue' let refValue = ref<string>('Chris1993'); // refValue 类型为: Ref<string> let setRefValue = () => { refValue.value = 'Hello Chris1993'; // ok! refValue.value = 1993; // error! } // reactive也类似 let reactiveValue = reactive<{name: string}>({name: 'Chris1993'});6. 把 ref 值作为 reactive 参数会怎么样?当我们已有一个 ref对象,需要使用在 reactive对象中,会发生什么呢?假设:let name = ref('Chris1993'); let nameReactive = reactive({name})我们可以做下列操作:let name = ref('Chris1993'); let nameReactive = reactive({name}) console.log(name.value === nameReactive.name); // true name.value = 'Hello Chris1993'; console.log(name.value); // Hello Chris1993 console.log(nameReactive.name); // Hello Chris1993 nameReactive.name = 'Hi Chris1993'; console.log(name.value); // Hi Chris1993 console.log(nameReactive.name); // Hi Chris1993这是因为 reactive将会对所有深层的 refs进行解包,并且保持 ref的响应式。当通过赋值方式将 ref分配给 reactive属性时,ref也会自动被解包:let name = ref('Chris1993'); let nameReactive = reactive({}) nameReactive.name = name; console.log(name.value); // Chris1993 console.log(nameReactive.name); // Chris1993 console.log(name.value === nameReactive.name); // true7. 总结本文主要从入门角度和大家介绍reactive/ ref两个 API 的使用方式区别,还有使用过程中的几个问题。简单总结一下:reactive 一般用于对象/数组类型的数据,都不需要使用 .value;ref一般用于基础数据类型的数据,在 JS 中读取和修改时,需要使用 .value,在模版中使用时则不需要;reactive 可以修改深层属性值,并保持响应;reactive 返回值和源对象不同;reactive的属性值可以是 ref值;ref本质也是 reactive,ref(obj)等价于 reactive({value: obj})。下一篇将和大家分享精通篇,欢迎大家期待。
提到“响应式”三个字,大家立刻想到啥?响应式布局?响应式编程?从字面意思可以看出,具有“响应式”特征的事物会根据条件变化,使得目标自动作出对应变化。比如在“响应式布局”中,页面根据不同设备尺寸自动显示不同样式。Vue.js 中的响应式也是一样,当数据发生变化后,使用到该数据的视图也会相应进行自动更新。接下来我根据个人理解,和大家一起探索下 Vue.js 中的响应式原理,如有错误,欢迎指点😺~~一、Vue.js 响应式的使用现在有个很简单的需求,点击页面中 “leo” 文本后,文本内容修改为“你好,前端自习课”。我们可以直接操作 DOM,来完成这个需求:<span id="name">leo</span> const node = document.querySelector('#name') node.innerText = '你好,前端自习课';实现起来比较简单,当我们需要修改的数据有很多时(比如相同数据被多处引用),这样的操作将变得复杂。既然说到 Vue.js,我们就来看看 Vue.js 怎么实现上面需求:<template> <div id="app"> <span @click="setName">{{ name }}</span> </div> </template> <script> export default { name: "App", data() { return { name: "leo", }; }, methods: { setName() { this.name = "你好,前端自习课"; }, }, }; </script>观察上面代码,我们通过改变数据,来自动更新视图。当我们有多个地方引用这个 name 时,视图都会自动更新。<template> <div id="app"> <span @click="setName">{{ name }}</span> <span>{{ name }}</span> <span>{{ name }}</span> <span>{{ name }}</span> </div> </template>当我们使用目前主流的前端框架 Vue.js 和 React 开发业务时,只需关注页面数据如何变化,因为数据变化后,视图也会自动更新,这让我们从繁杂的 DOM 操作中解脱出来,提高开发效率。二、回顾观察者模式前面反复提到“通过改变数据,来自动更新视图”,换个说法就是“数据改变后,使用该数据的地方被动发生响应,更新视图”。是不是有种熟悉的感觉?数据无需关注自身被多少对象引用,只需在数据变化时,通知到引用的对象即可,引用的对象作出响应。恩,有种观察者模式的味道?关于观察者模式,可阅读我之前写的《图解设计模式之观察者模式(TypeScript)》。1. 观察者模式流程观察者模式表示一种“一对多”的关系,n 个观察者关注 1 个被观察者,被观察者可以主动通知所有观察者。接下图:在这张图中,粉丝想及时收到“前端自习课”最新文章,只需关注即可,“前端自习课”有新文章,会主动推送给每个粉丝。该过程中,“前端自习课”是被观察者,每位“粉丝”是观察者。2. 观察者模式核心观察者模式核心组成包括:n 个观察者和 1 个被观察者。这里实现一个简单观察者模式:2.1 定义接口// 观察目标接口 interface ISubject { addObserver: (observer: Observer) => void; // 添加观察者 removeObserver: (observer: Observer) => void; // 移除观察者 notify: () => void; // 通知观察者 } // 观察者接口 interface IObserver { update: () => void; }2.2 实现被观察者类// 实现被观察者类 class Subject implements ISubject { private observers: IObserver[] = []; public addObserver(observer: IObserver): void { this.observers.push(observer); } public removeObserver(observer: IObserver): void { const idx: number = this.observers.indexOf(observer); ~idx && this.observers.splice(idx, 1); } public notify(): void { this.observers.forEach(observer => { observer.update(); }); } }2.3 实现观察者类// 实现观察者类 class Observer implements IObserver { constructor(private name: string) { } update(): void { console.log(`${this.name} has been notified.`); } }2.4 测试代码function useObserver(){ const subject: ISubject = new Subject(); const Leo = new Observer("Leo"); const Robin = new Observer("Robin"); const Pual = new Observer("Pual"); subject.addObserver(Leo); subject.addObserver(Robin); subject.addObserver(Pual); subject.notify(); subject.removeObserver(Pual); subject.notify(); } useObserver(); // [LOG]: "Leo has been notified." // [LOG]: "Robin has been notified." // [LOG]: "Pual has been notified." // [LOG]: "Leo has been notified." // [LOG]: "Robin has been notified."三、回顾 Object.defineProperty()Vue.js 的数据响应式原理是基于 JS 标准内置对象方法 Object.defineProperty() 方法来实现,该方法不兼容 IE8 和 FF22 及以下版本浏览器,这也是为什么 Vue.js 只能在这些版本之上的浏览器中才能运行的原因。理解 Object.defineProperty() 对我们理解 Vue.js 响应式原理非常重要。Vue.js 3 使用 proxy 方法实现响应式,两者类似,我们只需搞懂Object.defineProperty() , proxy 也就差不多理解了。1. 概念介绍Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。语法如下:Object.defineProperty(obj, prop, descriptor)入参说明:obj :要定义属性的源对象;prop :要定义或修改的属性名称或 Symbol;descriptor :要定义或修改的属性描述符,包括 configurable、enumerable、value、writable、get、set,具体的可以去参阅文档;出参说明:修改后的源对象。举个简单🌰例子:const leo = {}; Object.defineProperty(leo, 'age', { value: 18, writable: true }) console.log(leo.age); // 18 leo.age = 22; console.log(leo.age); // 222. 实现 getter/setter我们知道 Object.defineProperty() 方法第三个参数是属性描述符(descriptor),支持设置 get 和 set 描述符:get 描述符:当访问该属性时,会调用此函数,默认值为 undefined ;set 描述符:当修改该属性时,会调用此函数,默认值为 undefined 。一旦对象拥有了 getter/setter 方法,我们可以简单将该对象称为响应式对象。这两个操作符为我们提供拦截数据进行操作的可能性,修改前面示例,添加 getter/setter 方法: let leo = {}, age = 18; Object.defineProperty(leo, 'age', { get(){ // to do something console.log('监听到请求数据'); return age; }, set(newAge){ // to do something console.log('监听到修改数据'); age = newAge > age ? age : newAge } }) leo.age = 20; // 监听到修改数据 console.log(leo.age); // 监听到请求数据 // 18 leo.age = 10; // 监听到修改数据 console.log(leo.age); // 监听到请求数据 // 10访问 leo 对象的 age 属性,会通过 get 描述符处理,而修改 age 属性,则会通过 set 描述符处理。四、实现简单的数据响应式通过前面两个小节,我们复习了“观察者模式”和“Object.defineProperty()” 方法,这两个知识点在 Vue.js 响应式原理中非常重要。接下来我们来实现一个很简单的数据响应式变化,需求如下:点击“更新数据”按钮,文本更新。接下来我们将实现三个类:Dep 被观察者类,用来生成被观察者;Watcher 观察者类,用来生成观察者;Observer 类,将普通数据转换为响应式数据,从而实现响应式对象。用一张图来描述三者之间关系,现在看不懂没关系,这小节看完可以再回顾这张图:1. 实现精简观察者模式这里参照前面复习“观察者模式”的示例,做下精简:// 实现被观察者类 class Dep { constructor() { this.subs = []; } addSub(watcher) { this.subs.push(watcher); } notify(data) { this.subs.forEach(sub => sub.update(data)); } } // 实现观察者类 class Watcher { constructor(cb) { this.cb = cb; } update(data) { this.cb(data); } }Vue.js 响应式原理中,观察者模式起到非常重要的作用。其中:Dep 被观察者类,提供用来收集观察者( addSub )方法和通知观察者( notify )方法;Watcher 观察者类,实例化时支持传入回调( cb )方法,并提供更新( update )方法;2. 实现生成响应式的类这一步需要实现 Observer 类,核心是通过 Object.defineProperty() 方法为对象的每个属性设置 getter/setter,目的是将普通数据转换为响应式数据,从而实现响应式对象。这里以最简单的单层对象为例(下一节会介绍深层对象),如:let initData = { text: '你好,前端自习课', desc: '每日清晨,享受一篇前端优秀文章。' };接下来实现 Observer 类:// 实现响应式类(最简单单层的对象,暂不考虑深层对象) class Observer { constructor (node, data) { this.defineReactive(node, data) } // 实现数据劫持(核心方法) // 遍历 data 中所有的数据,都添加上 getter 和 setter 方法 defineReactive(vm, obj) { //每一个属性都重新定义get、set for(let key in obj){ let value = obj[key], dep = new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { // 创建观察者 let watcher = new Watcher(v => vm.innerText = v); dep.addSub(watcher); return value; }, set(newValue) { value = newValue; // 通知所有观察者 dep.notify(newValue); } }) } } }上面代码的核心是 defineReactive 方法,它遍历原始对象中每个属性,为每个属性实例化一个被观察者(Dep),然后分别调用 Object.defineProperty() 方法,为每个属性添加 getter/setter。访问数据时,getter 执行依赖收集(即添加观察者),通过实例化 Watcher 创建一个观察者,并执行被观察者的 addSub() 方法添加一个观察者;修改数据时,setter 执行派发更新(即通知观察者),通过调用被观察者的 notify() 方法通知所有观察者,执行观察者 update() 方法。3. 测试代码为了方便观察数据变化,我们为“更新数据”按钮绑定点击事件来修改数据:<div id="app"></div> <button id="update">更新数据</button>测试代码如下:// 初始化测试数据 let initData = { text: '你好,前端自习课', desc: '每日清晨,享受一篇前端优秀文章。' }; const app = document.querySelector('#app'); // 步骤1:为测试数据转换为响应式对象 new Observer(app, initData); // 步骤2:初始化页面文本内容 app.innerText = initData.text; // 步骤3:绑定按钮事件,点击触发测试 document.querySelector('#update').addEventListener('click', function(){ initData.text = `我们必须经常保持旧的记忆和新的希望。`; console.log(`当前时间:${new Date().toLocaleString()}`) })测试代码中,核心在于通过实例化 Observer,将测试数据转换为响应式数据,然后模拟数据变化,来观察视图变化。每次点击“更新数据”按钮,在控制台中都能看到“数据发生变化!”的提示,说明我们已经能通过 setter 观察到数据的变化情况。当然,你还可以在控制台手动修改 initData 对象中的 text 属性,来体验响应式变化~~到这里,我们实现了非常简单的数据响应式变化,当然 Vue.js 肯定没有这么简单,这个先理解,下一节看 Vue.js 响应式原理,思路就会清晰很多。这部分代码,我已经放到我的 Github,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/Basics-Reactive-Demo.js可以再回顾下这张图,对整个过程会更清晰:五、Vue.js 响应式实现本节代码:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/这里大家可以再回顾下下面这张官网经典的图,思考下前面讲的示例。(图片来自:https://cn.vuejs.org/v2/guide/reactivity.html)上一节实现了简单的数据响应式,接下来继续通过完善该示例,实现一个简单的 Vue.js 响应式,测试代码如下:// index.js const vm = new Vue({ el: '#app', data(){ return { text: '你好,前端自习课', desc: '每日清晨,享受一篇前端优秀文章。' } } });是不是很有内味了,下面是我们最终实现后项目目录:- mini-reactive / index.html // 入口 HTML 文件 / index.js // 入口 JS 文件 / observer.js // 实现响应式,将数据转换为响应式对象 / watcher.js // 实现观察者和被观察者(依赖收集者) / vue.js // 实现 Vue 类作为主入口类 / compile.js // 实现编译模版功能知道每一个文件功能以后,接下来将每一步串联起来。1. 实现入口文件我们首先实现入口文件,包括 index.html / index.js 2 个简单文件,用来方便接下来的测试。1.1 index.html<!DOCTYPE html> <html lang="en"> <head> <script src="./vue.js"></script> <script src="./observer.js"></script> <script src="./compile.js"></script> <script src="./watcher.js"></script> </head> <body> <div id="app">{{text}}</div> <button id="update">更新数据</button> <script src="./index.js"></script> </body> </html>1.2 index.js"use strict"; const vm = new Vue({ el: '#app', data(){ return { text: '你好,前端自习课', desc: '每日清晨,享受一篇前端优秀文章。' } } }); console.log(vm.$data.text) vm.$data.text = '页面数据更新成功!'; // 模拟数据变化 /* 也可以手动绑定“更新数据”按钮的事件,来手动更新数据 document.getElementById('update').addEventListener('click', function(){ vm.$data.text = '我们必须经常保持旧的记忆和新的希望。'; }) */ console.log(vm.$data.text)2. 实现核心入口 vue.jsvue.js 文件是我们实现的整个响应式的入口文件,暴露一个 Vue 类,并挂载全局。class Vue { constructor (options = {}) { this.$el = options.el; this.$data = options.data(); this.$methods = options.methods; // [核心流程]将普通 data 对象转换为响应式对象 new Observer(this.$data); if (this.$el) { // [核心流程]将解析模板的内容 new Compile(this.$el, this) } } } window.Vue = Vue;Vue 类入参为一个配置项 option ,使用起来跟 Vue.js 一样,包括 $el 挂载点、 $data 数据对象和 $methods 方法列表(本文不详细介绍)。通过实例化 Oberser 类,将普通 data 对象转换为响应式对象,然后判断是否传入 el 参数,存在时,则实例化 Compile 类,解析模版内容。总结下 Vue 这个类工作流程 :3. 实现 observer.jsobserver.js 文件实现了 Observer 类,用来将普通对象转换为响应式对象:class Observer { constructor (data) { this.data = data; this.walk(data); } // [核心方法]将 data 对象转换为响应式对象,为每个 data 属性设置 getter 和 setter 方法 walk (data) { if (typeof data !== 'object') return data; Object.keys(data).forEach( key => { this.defineReactive(data, key, data[key]) }) } // [核心方法]实现数据劫持 defineReactive (obj, key, value) { this.walk(value); // [核心过程]遍历 walk 方法,处理深层对象。 const dep = new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get () { console.log('[getter]方法执行') Dep.target && dep.addSub(Dep.target); return value }, set (newValue) { console.log('[setter]方法执行') if (value === newValue) return; // [核心过程]当设置的新值 newValue 为对象,则继续通过 walk 方法将其转换为响应式对象 if (typeof newValue === 'object') this.walk(newValue); value = newValue; dep.notify(); // [核心过程]执行被观察者通知方法,通知所有观察者执行 update 更新 } }) } }相比较第四节实现的 Observer 类,这里做了调整:增加 walk 核心方法,用来遍历对象每个属性,分别调用数据劫持方法( defineReactive() );在 defineReactive() 的 getter 中,判断 Dep.target 存在才添加观察者,下一节会详细介绍 Dep.target;在 defineReactive() 的 setter 中,判断当前新值( newValue )是否为对象,如果是,则直接调用 this.walk() 方法将当前对象再次转为响应式对象,处理深层对象。通过改善后的 Observer 类,我们就可以实现将单层或深层嵌套的普通对象转换为响应式对象。4. 实现 watcher.js这里实现了 Dep 被观察者类(依赖收集者)和 Watcher 观察者类。class Dep { constructor() { this.subs = []; } addSub(watcher) { this.subs.push(watcher); } notify(data) { this.subs.forEach(sub => sub.update(data)); } } class Watcher { constructor (vm, key, cb) { this.vm = vm; // vm:表示当前实例 this.key = key; // key:表示当前操作的数据名称 this.cb = cb; // cb:表示数据发生改变之后的回调 Dep.target = this; // 全局唯一 // 此处通过 this.vm.$data[key] 读取属性值,触发 getter this.oldValue = this.vm.$data[key]; // 保存变化的数据作为旧值,后续作判断是否更新 // 前面 getter 执行完后,执行下面清空 Dep.target = null; } update () { console.log(`数据发生变化!`); let oldValue = this.oldValue; let newValue = this.vm.$data[this.key]; if (oldValue != newValue) { // 比较新旧值,发生变化才执行回调 this.cb(newValue, oldValue); }; } }相比较第四节实现的 Watcher 类,这里做了调整:在构造函数中,增加 Dep.target 值操作;在构造函数中,增加 oldValue 变量,保存变化的数据作为旧值,后续做为判断是否更新的依据;在 update() 方法中,增加当前操作对象 key 对应值的新旧值比较,如果不同,才执行回调。Dep.target 是当前全局唯一的订阅者,因为同一时间只允许一个订阅者被处理。target 指当前正在处理的目标订阅者,当前订阅者处理完就赋值为 null 。这里 Dep.target 会在 defineReactive() 的 getter 中使用到。通过改善后的 Watcher 类,我们操作当前操作对象 key 对应值的时候,可以在数据有变化的情况才执行回调,减少资源浪费。4. 实现 compile.jscompile.js 实现了 Vue.js 的模版编译,如将 HTML 中的 {{text}} 模版转换为具体变量的值。compile.js 介绍内容较多,考虑到篇幅问题,并且本文核心介绍响应式原理,所以这里就暂时不介绍 compile.js 的实现,在学习的朋友可以到我 Github 上下载该文件直接下载使用即可,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/compile.js5. 测试代码到这里,我们已经将第四节的 demo 改造成简易版 Vue.js 响应式,接下来打开 index.html 看看效果:当 index.js 中执行到:vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';页面便发生更新,页面显示的文本内容从“你好,前端自习课”更新成“我们必须经常保持旧的记忆和新的希望。”。到这里,我们的简易版 Vue.js 响应式原理实现好了,能跟着文章看到这里的朋友,给你点个大大的赞👍六、总结本文首先通过回顾观察者模式和 Object.defineProperty() 方法,介绍 Vue.js 响应式原理的核心知识点,然后带大家通过一个简单示例实现简单响应式,最后通过改造这个简单响应式的示例,实现一个简单 Vue.js 响应式原理的示例。相信看完本文的朋友,对 Vue.js 的响应式原理的理解会更深刻,希望大家理清思路,再好好回味下~参考资料官方文档 - 深入响应式原理 《浅谈Vue响应式原理》《Vue的数据响应式原理》
一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第2天,点击查看活动详情。本文会和大家详细介绍 TypeScript 中的映射类型(Mapped Type),看完本文你将学到以下知识点:数学中的映射和 TS 中的映射类型的关系;TS 中映射类型的应用;TS 中映射类型修饰符的应用;接下来会先从「数学中的映射」开始介绍。本文使用到的 TypeScript 版本为 v4.6.2。如果你对 TypeScript 还不熟悉,可以看下面几篇资料:一份不可多得的 TS 学习指南(1.8W字)了不起的 TypeScript 入门教程一、什么是映射?在学习 TypeScript 类型系统时,尽量多和数学中的集合类比学习,比如 TypeScript 中的联合类型,类似数学中的并集等。在数学中,映射是指两个元素的集合之间元素相互对应的关系,比如下图: (来源:baike.baidu.com/item/%E6%98…)可以将映射理解为函数,如上图,当我们需要将集合 A 的元素转换为集合 B 的元素,可以通过 f函数做映射,比如将集合 A 的元素 1对应到集合 B 中的元素 2。 这样就能很好的实现映射过程的复用。二、TypeScript 中的映射类型是什么?1. 概念介绍TypeScript 中的映射类型和数学中的映射类似,能够将一个集合的元素转换为新集合的元素,只是 TypeScript 映射类型是将一个类型映射成另一个类型。在我们实际开发中,经常会需要一个类型的所有属性转换为可选类型,这时候你可以直接使用 TypeScript 中的 Partial工具类型:type User = { name: string; location: string; age: number; } type User2 = Partial<User>; /* User2 的类型: type User2 = { name?: string | undefined; location?: string | undefined; age?: number | undefined; } */这样我们就实现了将 User类型映射成 User2类型,并且将 User类型中的所有属性转为可选类型。2. 实现方法TypeScript 映射类型的语法如下:type TypeName<Type> = { [Property in keyof Type]: boolean; };我们既然可以通过 Partial工具类型非常简单的实现将指定类型的所有属性转换为可选类型,那其内容原理又是如何?我们可以在编辑器中,将鼠标悬停在 Partial名称上面,可以看到编辑器提示如下:拆解一下其中每个部分:type Partial<T>:定义一个类型别名 Partial和泛型 T;keyof T:通过 keyof操作符获取泛型 T中所有 key,返回一个联合类型(如果不清楚什么是联合类型,可以理解为一个数组);type User = { name: string; location: string; age: number; } type KeyOfUser = keyof User; // "name" | "location" | "age"in:类似 JS 中 for...in中的 in,用来遍历目标类型的公开属性名;T[P]:是个索引访问类型(也称查找类型),获取泛型 T中 P类型,类似 JS 中的访问对象的方式;?:将类型值设置为可选类型;{ [P in keyof T] ?: T[P] | undefined}:遍历 keyof T返回的联合类型,并定义用 P变量接收,其每次遍历返回的值为可选类型的 T[P]。这样就实现了 Partial工具类型,这种操作方法非常重要,是后面进行 TypeScript 类型体操的重要基础。关于类型体操的练习,有兴趣可以看看这篇文章:《这 30 道 TS 练习题,你能答对几道?》juejin.cn/post/700904…三、映射类型的应用TypeScript 映射类型经常用来复用一些对类型的操作过程,比如 TypeScript 目前支持的 21 种工具类型,将我们常用的一些类型操作定义成这些工具类型,方便开发者复用这些类型。所有已支持的工具类型可以看下官方文档:www.typescriptlang.org/docs/handbo…下面我们挑几个常用的工具类型,看下其实现过程中是如何使用映射类型的。在学习 TypeScript 过程中,推荐多在官方的 Playground 练习和学习:www.typescriptlang.org/zh/play1. Required 必选属性用来将类型的所有属性设置为必选属性。实现如下:type Required<T> = { [P in keyof T]-?: T[P]; };使用方式:type User = { name?: string; location?: string; age?: number; } type User2 = Required<User>; /* type User2 = { name: string; location: string; age: number; } */ const user: User2 = { name: 'pingan8787', age: 18 } /* 报错: Property 'location' is missing in type '{ name: string; age: number; }' but required in type 'Required<User>'. */这边的 -?符号可以暂时理解为“将可选属性转换为必选属性”,下一节会详细介绍这些符号。2. Readonly 只读属性用来将所有属性的类型设置为只读类型,即不能重新分配类型。实现如下:type Readonly<T> = { readonly [P in keyof T]: T[P]; }使用方式:type User = { name?: string; location?: string; age?: number; } type User2 = Readonly<User>; /* type User2 = { readonly name?: string | undefined; readonly location?: string | undefined; readonly age?: number | undefined; } */ const user: User2 = { name: 'pingan8787', age: 18 } user.age = 20; /* 报错: Cannot assign to 'age' because it is a read-only property. */3. Pick 选择指定属性用来从指定类型中选择指定属性并返回。实现如下:type Pick<T, K extends keyof T> = { [P in K]: T[P]; }使用如下:type User = { name?: string; location?: string; age?: number; } type User2 = Pick<User, 'name' | 'age'>; /* type User2 = { name?: string | undefined; age?: number | undefined; } */ const user1: User2 = { name: 'pingan8787', age: 18 } const user2: User2 = { name: 'pingan8787', location: 'xiamen', // 报错 age: 18 } /* 报错 Type '{ name: string; location: string; age: number; }' is not assignable to type 'User2'. Object literal may only specify known properties, and 'location' does not exist in type 'User2'. */4. Omit 忽略指定属性作用类似与 Pick工具类型相反,可以从指定类型中忽略指定的属性并返回。实现如下:type Omit<T, K extends string | number | symbol> = { [P in Exclude<keyof T, K>]: T[P]; }使用方式:type User = { name?: string; location?: string; age?: number; } type User2 = Omit<User, 'name' | 'age'>; /* type User2 = { location?: string | undefined; } */ const user1: User2 = { location: 'xiamen', } const user2: User2 = { name: 'pingan8787', // 报错 location: 'xiamen' } /* 报错: Type '{ name: string; location: string; }' is not assignable to type 'User2'. Object literal may only specify known properties, and 'name' does not exist in type 'User2'. */5. Exclude 从联合类型中排除指定类型用来从指定的联合类型中排除指定类型。实现如下:type Exclude<T, U> = T extends U ? never : T;使用方式:type User = { name?: string; location?: string; age?: number; } type User2 = Exclude<keyof User, 'name'>; /* type User2 = "location" | "age" */ const user1: User2 = 'age'; const user2: User2 = 'location'; const user3: User2 = 'name'; // 报错 /* 报错: Type '"name"' is not assignable to type 'User2'. */四、映射修饰符的应用在自定义映射类型的时候,我们可以使用两个映射类型的修饰符来实现我们的需求:readonly修饰符:将指定属性设置为只读类型;?修饰符:将指定属性设置为可选类型;前面介绍 Readonly和 Partial工具类型的时候已经使用到:type Readonly<T> = { readonly [P in keyof T]: T[P]; } type Partial<T> = { [P in keyof T]?: T[P] | undefined; }当然,也可以对修饰符进行操作:+添加修饰符(默认使用);-删除修饰符;比如:type Required<T> = { [P in keyof T]-?: T[P]; // 通过 - 删除 ? 修饰符 };也可以放在前面使用:type NoReadonly<T> = { -readonly [P in keyof T]: T[P]; // 通过 - 删除 readonly 修饰符 }五、总结本文从数学中的映射作为切入点,详细介绍 TypeScript 映射类型(Mapped Type)并介绍映射类型的应用和修饰符的应用。在学习 TypeScript 类型系统时,尽量多和数学中的集合类比学习,比如 TypeScript 中的联合类型,类似数学中的并集等。学好映射类型,是接下来做类型体操中非常重要的基础~~参考资料TypeScript 文档-映射类型:www.typescriptlang.org/docs/handbo…TypeScript 工具类型: www.typescriptlang.org/docs/handbo…
二、Vite1. Vite 动态导入的使用问题文档地址:cn.vitejs.dev/guide/featu…使用 webpack 的同学应该都知道,在 webpack 中可以通过 require.context动态导入文件:// https://webpack.js.org/guides/dependency-management/ require.context('./test', false, /\.test\.js$/);在 Vite 中,我们可以使用这两个方法来动态导入文件:import.meta.glob该方法匹配到的文件默认是懒加载,通过动态导入实现,构建时会分离独立的 chunk,是异步导入,返回的是 Promise,需要做异步操作,使用方式如下:const Components = import.meta.glob('../components/**/*.vue'); // 转译后: const Components = { './components/a.vue': () => import('./components/a.vue'), './components/b.vue': () => import('./components/b.vue') }import.meta.globEager该方法是直接导入所有模块,并且是同步导入,返回结果直接通过 for...in循环就可以操作,使用方式如下:const Components = import.meta.globEager('../components/**/*.vue'); // 转译后: import * as __glob__0_0 from './components/a.vue' import * as __glob__0_1 from './components/b.vue' const modules = { './components/a.vue': __glob__0_0, './components/b.vue': __glob__0_1 }如果仅仅使用异步导入 Vue3 组件,也可以直接使用 Vue3 defineAsyncComponent API 来加载:// https://v3.cn.vuejs.org/api/global-api.html#defineasynccomponent import { defineAsyncComponent } from 'vue' const AsyncComp = defineAsyncComponent(() => import('./components/AsyncComponent.vue') ) app.component('async-component', AsyncComp)2. Vite 配置 alias 类型别名文档地址:cn.vitejs.dev/config/#res…当项目比较复杂的时候,经常需要配置 alias 路径别名来简化一些代码:import Home from '@/views/Home.vue' 在 Vite 中配置也很简单,只需要在 vite.config.ts 的 resolve.alias中配置即可:// vite.config.ts export default defineConfig({ base: './', resolve: { alias: { "@": path.join(__dirname, "./src") }, } // 省略其他配置 })如果使用的是 TypeScript 时,编辑器会提示路径不存在的警告⚠️,这时候可以在 tsconfig.json中添加 compilerOptions.paths的配置:{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }3. Vite 配置全局 scss文档地址:cn.vitejs.dev/config/#css…当我们需要使用 scss 配置的主题变量(如 $primary)、mixin方法(如 @mixin lines)等时,如:<script setup lang="ts"> </script> <template> <div class="container"></div> </template> <style scoped lang="scss"> .container{ color: $primary; @include lines; } </style>我们可以将 scss 主题配置文件,配置在 vite.config.ts 的 css.preprocessorOptions.scss.additionalData中:// vite.config.ts export default defineConfig({ base: './', css: { preprocessorOptions: { // 添加公共样式 scss: { additionalData: '@import "./src/style/style.scss";' } } }, plugins: [vue()] // 省略其他配置 })如果不想使用 scss 配置文件,也可以直接写成 scss 代码:export default defineConfig({ css: { preprocessorOptions: { scss: { additionalData: '$primary: #993300' } } } })三、VueRouter1. script-setup 模式下获取路由参数文档地址:router.vuejs.org/zh/guide/ad…由于在 script-setup模式下,没有 this可以使用,就不能直接通过 this.$router或 this.$route来获取路由参数和跳转路由。当我们需要获取路由参数时,就可以使用 vue-router提供的 useRoute方法来获取,使用如下:// A.vue <script setup lang="ts"> import { ref, onMounted } from 'vue'; import router from "@/router"; import { useRoute } from 'vue-router' let detailId = ref<string>(''); onMounted(() => { const route = useRoute(); detailId.value = route.params.id as string; // 获取参数 }) </script>如果要做路由跳转,就可以使用 useRouter方法的返回值去跳转:const router = useRouter(); router.push({ name: 'search', query: {/**/}, })四、Pinia1. store 解构的变量修改后没有更新文档地址:pinia.vuejs.org/core-concep…当我们解构出 store 的变量后,再修改 store 上该变量的值,视图没有更新:// A.vue <script setup lang="ts"> import componentStore from "@/store/component"; const componentStoreObj = componentStore(); let { name } = componentStoreObj; const changeName = () => { componentStoreObj.name = 'hello pingan8787'; } </script> <template> <span @click="changeName">{{name}}</span> </template>这时候点击按钮触发 changeName事件后,视图上的 name 并没有变化。这是因为 store 是个 reactive 对象,当进行解构后,会破坏它的响应性。所以我们不能直接进行解构。这种情况就可以使用 Pinia 提供 storeToRefs工具方法,使用起来也很简单,只需要将需要解构的对象通过 storeToRefs方法包裹,其他逻辑不变:// A.vue <script setup lang="ts"> import componentStore from "@/store/component"; import { storeToRefs } from 'pinia'; const componentStoreObj = componentStore(); let { name } = storeToRefs(componentStoreObj); // 使用 storeToRefs 包裹 const changeName = () => { componentStoreObj.name = 'hello pingan8787'; } </script> <template> <span @click="changeName">{{name}}</span> </template>这样再修改其值,变更马上更新视图了。2. Pinia 修改数据状态的方式按照官网给的方案,目前有三种方式修改:通过 store.属性名赋值修改单笔数据的状态;这个方法就是前面一节使用的:const changeName = () => { componentStoreObj.name = 'hello pingan8787'; }通过 $patch方法修改多笔数据的状态;文档地址:pinia.vuejs.org/api/interfa…当我们需要同时修改多笔数据的状态时,如果还是按照上面方法,可能要这么写:const changeName = () => { componentStoreObj.name = 'hello pingan8787' componentStoreObj.age = '18' componentStoreObj.addr = 'xiamen' }上面这么写也没什么问题,但是 Pinia 官网已经说明,使用 $patch的效率会更高,性能更好,所以在修改躲避数据时,更推荐使用 $patch,使用方式也很简单:const changeName = () => { // 参数类型1:对象 componentStoreObj.$patch({ name: 'hello pingan8787', age: '18', addr: 'xiamen', }) // 参数类型2:方法,该方法接收 store 中的 state 作为参数 componentStoreObj.$patch(state => { state.name = 'hello pingan8787'; state.age = '18'; state.addr = 'xiamen'; }) }通过 action方法修改多笔数据的状态;也可以在 store 中定义 actions 的一个方法来更新:// store.ts import { defineStore } from 'pinia'; export default defineStore({ id: 'testStore', state: () => { return { name: 'pingan8787', age: '10', addr: 'fujian' } }, actions: { updateState(){ this.name = 'hello pingan8787'; this.age = '18'; this.addr = 'xiamen'; } } }) 使用时:const changeName = () => { componentStoreObj.updateState(); }这三种方式都能更新 Pinia 中 store 的数据状态。五、Element Plus1. element-plus 打包时 @charset 警告项目新安装的 element-plus 在开发阶段都是正常,没有提示任何警告,但是在打包过程中,控制台输出下面警告内容: 在官方 issues 中查阅很久:github.com/element-plu…。尝试在 vite.config.ts中配置 charset: false,结果也是无效:// vite.config.ts export default defineConfig({ css: { preprocessorOptions: { scss: { charset: false // 无效 } } } })最后在官方的 issues 中找到处理方法:// vite.config.ts // https://blog.csdn.net/u010059669/article/details/121808645 css: { postcss: { plugins: [ // 移除打包element时的@charset警告 { postcssPlugin: 'internal:charset-removal', AtRule: { charset: (atRule) => { if (atRule.name === 'charset') { atRule.remove(); } } } } ], }, }2. 中文语言包配置文档地址:element-plus.gitee.io/zh-CN/guide…默认 elemnt-plus 的组件是英文状态: 我们可以通过引入中文语言包,并添加到 ElementPlus 配置中来切换成中文:// main.ts // ... 省略其他 import ElementPlus from 'element-plus'; import 'element-plus/dist/index.css'; import locale from 'element-plus/lib/locale/lang/zh-cn'; // element-plus 中文语言包 app.use(ElementPlus, { locale }); // 配置中文语言包这时候就能看到 ElementPlus 里面组件的文本变成中文了。 总结以上是我最近从入门到实战 Vue3 全家桶的 3 个项目后总结避坑经验,其实很多都是文档中有介绍的,只是刚开始不熟悉。也希望大伙多看看文档咯~Vue3 script-setup 模式确实越写越香。本文内容如果有问题,欢迎大家一起评论讨论。
一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。最近入门 Vue3 并完成 3 个项目,遇到问题蛮多的,今天就花点时间整理一下,和大家分享 15 个比较常见的问题,基本都贴出对应文档地址,还请多看文档~已经完成的 3 个项目基本都是使用 Vue3 (setup-script 模式)全家桶开发,因此主要分几个方面总结:Vue3ViteVueRouterPiniaElementPlus一、Vue31. Vue2.x 和 Vue3.x 生命周期方法的变化文档地址:v3.cn.vuejs.org/guide/compo…Vue2.x 和 Vue3.x 生命周期方法的变化蛮大的,先看看:2.x 生命周期3.x 生命周期执行时间说明beforeCreatesetup组件创建前执行createdsetup组件创建后执行beforeMountonBeforeMount组件挂载到节点上之前执行mountedonMounted组件挂载完成后执行beforeUpdateonBeforeUpdate组件更新之前执行updatedonUpdated组件更新完成之后执行beforeDestroyonBeforeUnmount组件卸载之前执行destroyedonUnmounted组件卸载完成后执行errorCapturedonErrorCaptured当捕获一个来自子孙组件的异常时激活钩子函数目前 Vue3.x 依然支持 Vue2.x 的生命周期,但不建议混搭使用,前期可以先使用 2.x 的生命周期,后面尽量使用 3.x 的生命周期开发。由于我使用都是 script-srtup模式,所以都是直接使用 Vue3.x 的生命周期函数:// A.vue <script setup lang="ts"> import { ref, onMounted } from "vue"; let count = ref<number>(0); onMounted(() => { count.value = 1; }) </script>每个钩子的执行时机点,也可以看看文档: v3.cn.vuejs.org/guide/insta…2. script-setup 模式中父组件获取子组件的数据文档地址:v3.cn.vuejs.org/api/sfc-scr…这里主要介绍父组件如何去获取子组件内部定义的变量,关于父子组件通信,可以看文档介绍比较详细: v3.cn.vuejs.org/guide/compo…我们可以使用全局编译器宏的defineExpose宏,将子组件中需要暴露给父组件获取的参数,通过 {key: vlaue}方式作为参数即可,父组件通过模版 ref 方式获取子组件实例,就能获取到对应值:// 子组件 <script setup> let name = ref("pingan8787") defineExpose({ name }); // 显式暴露的数据,父组件才可以获取 </script> // 父组件 <Chlid ref="child"></Chlid> <script setup> let child = ref(null) child.value.name //获取子组件中 name 的值为 pingan8787 </script>注意:全局编译器宏只能在 script-setup 模式下使用;script-setup 模式下,使用宏时无需 import可以直接使用;script-setup 模式一共提供了 4 个宏,包括:defineProps、defineEmits、defineExpose、withDefaults。3. 为 props 提供默认值definedProps 文档:v3.cn.vuejs.org/api/sfc-scr… withDefaults 文档:v3.cn.vuejs.org/api/sfc-scr…前面介绍 script-setup 模式提供的 4 个全局编译器宏,还没有详细介绍,这一节介绍 defineProps和 withDefaults。使用 defineProps宏可以用来定义组件的入参,使用如下:<script setup lang="ts"> let props = defineProps<{ schema: AttrsValueObject; modelValue: any; }>(); </script>这里只定义props属性中的 schema和 modelValue两个属性的类型, defineProps 的这种声明的不足之处在于,它没有提供设置 props 默认值的方式。其实我们可以通过 withDefaults 这个宏来实现:<script setup lang="ts"> let props = withDefaults( defineProps<{ schema: AttrsValueObject; modelValue: any; }>(), { schema: [], modelValue: '' } ); </script>withDefaults 辅助函数提供了对默认值的类型检查,并确保返回的 props 的类型删除了已声明默认值的属性的可选标志。4. 配置全局自定义参数文档地址:v3.cn.vuejs.org/guide/migra…在 Vue2.x 中我们可以通过 Vue.prototype 添加全局属性 property。但是在 Vue3.x 中需要将 Vue.prototype 替换为 config.globalProperties 配置:// Vue2.x Vue.prototype.$api = axios; Vue.prototype.$eventBus = eventBus; // Vue3.x const app = createApp({}) app.config.globalProperties.$api = axios; app.config.globalProperties.$eventBus = eventBus;使用时需要先通过 vue 提供的 getCurrentInstance方法获取实例对象:// A.vue <script setup lang="ts"> import { ref, onMounted, getCurrentInstance } from "vue"; onMounted(() => { const instance = <any>getCurrentInstance(); const { $api, $eventBus } = instance.appContext.config.globalProperties; // do something }) </script>其中 instance内容输出如下: 5. v-model 变化文档地址:v3.cn.vuejs.org/guide/migra…当我们在使用 v-model指令的时候,实际上 v-bind 和 v-on 组合的简写,Vue2.x 和 Vue3.x 又存在差异。Vue2.x<ChildComponent v-model="pageTitle" /> <!-- 是以下的简写: --> <ChildComponent :value="pageTitle" @input="pageTitle = $event" />在子组件中,如果要对某一个属性进行双向数据绑定,只要通过 this.$emit('update:myPropName', newValue) 就能更新其 v-model绑定的值。Vue3.x<ChildComponent v-model="pageTitle" /> <!-- 是以下的简写: --> <ChildComponent :modelValue="pageTitle" @update:modelValue="pageTitle = $event"/>script-setup模式下就不能使用 this.$emit去派发更新事件,毕竟没有 this,这时候需要使用前面有介绍到的 defineProps、defineEmits 两个宏来实现:// 子组件 child.vue // 文档:https://v3.cn.vuejs.org/api/sfc-script-setup.html#defineprops-%E5%92%8C-defineemits <script setup lang="ts"> import { ref, onMounted, watch } from "vue"; const emit = defineEmits(['update:modelValue']); // 定义需要派发的事件名称 let curValue = ref(''); let props = withDefaults(defineProps<{ modelValue: string; }>(), { modelValue: '', }) onMounted(() => { // 先将 v-model 传入的 modelValue 保存 curValue.value = props.modelValue; }) watch(curValue, (newVal, oldVal) => { // 当 curValue 变化,则通过 emit 派发更新 emit('update:modelValue', newVal) }) </script> <template> <div></div> </template> <style lang="scss" scoped></style>父组件使用的时候就很简单:// 父组件 father.vue <script setup lang="ts"> import { ref, onMounted, watch } from "vue"; let curValue = ref(''); watch(curValue, (newVal, oldVal) => { console.log('[curValue 发生变化]', newVal) }) </script> <template> <Child v-model='curValue'></Child> </template> <style lang="scss" scoped></style>6. 开发环境报错不好排查文档地址:v3.cn.vuejs.org/api/applica…Vue3.x 对于一些开发过程中的异常,做了更友好的提示警告,比如下面这个提示: 这样能够更清楚的告知异常的出处,可以看出大概是 <ElInput 0=......这边的问题,但还不够清楚。 这时候就可以添加 Vue3.x 提供的全局异常处理器,更清晰的输出错误内容和调用栈信息,代码如下:// main.ts app.config.errorHandler = (err, vm, info) => { console.log('[全局异常]', err, vm, info) }这时候就能看到输出内容如下: 一下子就清楚很多。 当然,该配置项也可以用来集成错误追踪服务 Sentry 和 Bugsnag。推荐阅读:Vue3 如何实现全局异常处理?7. 观察 ref 的数据不直观,不方便当我们在控制台输出 ref声明的变量时。const count = ref<numer>(0); console.log('[测试 ref]', count)会看到控制台输出了一个 RefImpl对象: 看起来很不直观。我们都知道,要获取和修改 ref声明的变量的值,需要通过 .value来获取,所以你也可以:console.log('[测试 ref]', count.value);这里还有另一种方式,就是在控制台的设置面板中开启 「Enable custom formatters」选项。这时候你会发现,控制台输出的 ref的格式发生变化了: 更加清晰直观了。这个方法是我在《Vue.js 设计与实现》中发现的,但在文档也没有找到相关介绍,如果有朋友发现了,欢迎告知~
在开发组件库或者插件,经常会需要进行全局异常处理,从而实现:全局统一处理异常;为开发者提示错误信息;方案降级处理等等。那么如何实现上面功能呢? 本文先简单实现一个异常处理方法,然后结合 Vue3 源码中的实现详细介绍,最后总结实现异常处理的几个核心。本文 Vue3 版本为 3.0.11一、前端常见异常对于前端来说,常见的异常比较多,比如:JS 语法异常;Ajax 请求异常;静态资源加载异常;Promise 异常;iframe 异常;等等对于这些异常如何处理,可以阅读这两篇文章:《你不知道的前端异常处理》《如何优雅处理前端异常?》最常用的比如:1. window.onerror通过 window.onerror文档可知,当 JS 运行时发生错误(包括语法错误),触发 window.onerror():window.onerror = function(message, source, lineno, colno, error) { console.log('捕获到异常:',{message, source, lineno, colno, error}); }函数参数:message:错误信息(字符串)。可用于HTML onerror=""处理程序中的 event。source:发生错误的脚本URL(字符串)lineno:发生错误的行号(数字)colno:发生错误的列号(数字)error:Error对象(对象)若该函数返回true,则阻止执行默认事件处理函数。2. try...catch 异常处理另外,我们也经常会使用 try...catch 语句处理异常:try { // do something } catch (error) { console.error(error); }更多处理方式,可以阅读前面推荐的文章。3. 思考大家可以思考下,自己在业务开发过程中,是否也是经常要处理这些错误情况? 那么像 Vue3 这样复杂的库,是否也是到处通过 try...catch来处理异常呢? 接下来一起看看。二、实现简单的全局异常处理在开发插件或库时,我们可以通过 try...catch封装一个全局异常处理方法,将需要执行的方法作为参数传入,调用方只要关心调用结果,而无需知道该全局异常处理方法内部逻辑。 大致使用方法如下:const errorHandling = (fn, args) => { let result; try{ result = args ? fn(...args) : fn(); } catch (error){ console.error(error) } return result; }测试一下:const f1 = () => { console.log('[f1 running]') throw new Error('[f1 error!]') } errorHandling(f1); /* 输出: [f1 running] Error: [f1 error!] at f1 (/Users/wangpingan/leo/www/node/www/a.js:14:11) at errorHandling (/Users/wangpingan/leo/www/node/www/a.js:4:39) at Object.<anonymous> (/Users/wangpingan/leo/www/node/www/a.js:17:1) at Module._compile (node:internal/modules/cjs/loader:1095:14) at Object.Module._extensions..js (node:internal/modules/cjs/loader:1147:10) at Module.load (node:internal/modules/cjs/loader:975:32) at Function.Module._load (node:internal/modules/cjs/loader:822:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) at node:internal/main/run_main_module:17:47 */可以看到,当需要为方法做异常处理时,只要将该方法作为参数传入即可。 但是上面示例跟实际业务开发的逻辑差得有点多,实际业务中,我们经常会遇到方法的嵌套调用,那么我们试一下:const f1 = () => { console.log('[f1]') f2(); } const f2 = () => { console.log('[f2]') f3(); } const f3 = () => { console.log('[f3]') throw new Error('[f3 error!]') } errorHandling(f1) /* 输出: [f1 running] [f2 running] [f3 running] Error: [f3 error!] at f3 (/Users/wangpingan/leo/www/node/www/a.js:24:11) at f2 (/Users/wangpingan/leo/www/node/www/a.js:19:5) at f1 (/Users/wangpingan/leo/www/node/www/a.js:14:5) at errorHandling (/Users/wangpingan/leo/www/node/www/a.js:4:39) at Object.<anonymous> (/Users/wangpingan/leo/www/node/www/a.js:27:1) at Module._compile (node:internal/modules/cjs/loader:1095:14) at Object.Module._extensions..js (node:internal/modules/cjs/loader:1147:10) at Module.load (node:internal/modules/cjs/loader:975:32) at Function.Module._load (node:internal/modules/cjs/loader:822:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) */这样也是没问题的。那么接下来就是在 errorHandling方法的 catch分支实现对应异常处理即可。 接下来看看 Vue3 源码中是如何处理的?三、Vue3 如何实现异常处理理解完上面示例,接下来看看在 Vue3 源码中是如何实现异常处理的,其实现起来也是很简单。1. 实现异常处理方法在 errorHandling.ts 文件中定义了 callWithErrorHandling和 callWithAsyncErrorHandling两个处理全局异常的方法。 顾名思义,这两个方法分别处理:callWithErrorHandling:处理同步方法的异常;callWithAsyncErrorHandling:处理异步方法的异常。使用方式如下:callWithAsyncErrorHandling( handler, instance, ErrorCodes.COMPONENT_EVENT_HANDLER, args )代码实现大致如下:// packages/runtime-core/src/errorHandling.ts // 处理同步方法的异常 export function callWithErrorHandling( fn: Function, instance: ComponentInternalInstance | null, type: ErrorTypes, args?: unknown[] ) { let res try { res = args ? fn(...args) : fn(); // 调用原方法 } catch (err) { handleError(err, instance, type) } return res } // 处理异步方法的异常 export function callWithAsyncErrorHandling( fn: Function | Function[], instance: ComponentInternalInstance | null, type: ErrorTypes, args?: unknown[] ): any[] { // 省略其他代码 const res = callWithErrorHandling(fn, instance, type, args) if (res && isPromise(res)) { res.catch(err => { handleError(err, instance, type) }) } // 省略其他代码 }callWithErrorHandling方法处理的逻辑比较简单,通过简单的 try...catch 做一层封装。 而 callWithAsyncErrorHandling 方法就比较巧妙,通过将需要执行的方法传入 callWithErrorHandling方法处理,并将其结果通过 .catch方法进行处理。2. 处理异常在上面代码中,遇到报错的情况,都会通过 handleError()处理异常。其实现大致如下:// packages/runtime-core/src/errorHandling.ts // 异常处理方法 export function handleError( err: unknown, instance: ComponentInternalInstance | null, type: ErrorTypes, throwInDev = true ) { // 省略其他代码 logError(err, type, contextVNode, throwInDev) } function logError( err: unknown, type: ErrorTypes, contextVNode: VNode | null, throwInDev = true ) { // 省略其他代码 console.error(err) }保留核心处理逻辑之后,可以看到这边处理也是相当简单,直接通过 console.error(err)输出错误内容。3. 配置 errorHandler 自定义异常处理函数在使用 Vue3 时,也支持指定自定义异常处理函数,来处理组件渲染函数和侦听器执行期间抛出的未捕获错误。这个处理函数被调用时,可获取错误信息和相应的应用实例。 文档参考:《errorHandler》 使用方法如下,在项目 main.js文件中配置:// src/main.js app.config.errorHandler = (err, vm, info) => { // 处理错误 // `info` 是 Vue 特定的错误信息,比如错误所在的生命周期钩子 }那么 errorHandler()是何时执行的呢?我们继续看看源码中 handleError() 的内容,可以发现:// packages/runtime-core/src/errorHandling.ts export function handleError( err: unknown, instance: ComponentInternalInstance | null, type: ErrorTypes, throwInDev = true ) { const contextVNode = instance ? instance.vnode : null if (instance) { // 省略其他代码 // 读取 errorHandler 配置项 const appErrorHandler = instance.appContext.config.errorHandler if (appErrorHandler) { callWithErrorHandling( appErrorHandler, null, ErrorCodes.APP_ERROR_HANDLER, [err, exposedInstance, errorInfo] ) return } } logError(err, type, contextVNode, throwInDev) }通过 instance.appContext.config.errorHandler取到全局配置的自定义错误处理函数,存在时则执行,当然,这边也是通过前面定义的 callWithErrorHandling来调用。4. 调用 errorCaptured 生命周期钩子在使用 Vue3 的时候,也可以通过 errorCaptured生命周期钩子来捕获来自后代组件的错误。 文档参考:《errorCaptured》 入参如下:(err: Error, instance: Component, info: string) => ?boolean此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。 此钩子可以返回 false以阻止该错误继续向上传播。 有兴趣的同学可以通过文档,查看具体的错误传播规则。 使用方法如下,父组件监听 onErrorCaptured生命周期(示例代码使用 Vue3 setup 语法):<template> <Message></Message> </template> <script setup> // App.vue import { onErrorCaptured } from 'vue'; import Message from './components/Message.vue' onErrorCaptured(function(err, instance, info){ console.log('[errorCaptured]', err, instance, info) }) </script>子组件如下:<template> <button @click="sendMessage">发送消息</button> </template> <script setup> // Message.vue const sendMessage = () => { throw new Error('[test onErrorCaptured]') } </script>当点击「发送消息」按钮,控制台便输出错误:[errorCaptured] Error: [test onErrorCaptured] at Proxy.sendMessage (Message.vue:36:15) at _createElementVNode.onClick._cache.<computed>._cache.<computed> (Message.vue:3:39) at callWithErrorHandling (runtime-core.esm-bundler.js:6706:22) at callWithAsyncErrorHandling (runtime-core.esm-bundler.js:6715:21) at HTMLButtonElement.invoker (runtime-dom.esm-bundler.js:350:13) Proxy {sendMessage: ƒ, …} native event handler可以看到 onErrorCaptured生命周期钩子正常执行,并输出子组件 Message.vue内的异常。那么这个又是如何实现呢?还是看 errorHandling.ts 中的 handleError() 方法:// packages/runtime-core/src/errorHandling.ts export function handleError( err: unknown, instance: ComponentInternalInstance | null, type: ErrorTypes, throwInDev = true ) { const contextVNode = instance ? instance.vnode : null if (instance) { let cur = instance.parent // the exposed instance is the render proxy to keep it consistent with 2.x const exposedInstance = instance.proxy // in production the hook receives only the error code const errorInfo = __DEV__ ? ErrorTypeStrings[type] : type while (cur) { const errorCapturedHooks = cur.ec // ①取出组件配置的 errorCaptured 生命周期方法 if (errorCapturedHooks) { // ②循环执行 errorCaptured 中的每个 Hook for (let i = 0; i < errorCapturedHooks.length; i++) { if ( errorCapturedHooks[i](err, exposedInstance, errorInfo) === false ) { return } } } cur = cur.parent } // 省略其他代码 } logError(err, type, contextVNode, throwInDev) } 这边会先获取 instance.parent作为当前处理的组件实例进行递归,每次将取出组件配置的 errorCaptured 生命周期方法的数组并循环调用其每一个钩子,然后再取出当前组件的父组件作为参数,最后继续递归调用下去。5. 实现错误码和错误消息Vue3 还为异常定义了错误码和错误信息,在不同的错误情况有不同的错误码和错误信息,让我们能很方便定位到发生异常的地方。 错误码和错误信息如下:// packages/runtime-core/src/errorHandling.ts export const enum ErrorCodes { SETUP_FUNCTION, RENDER_FUNCTION, WATCH_GETTER, WATCH_CALLBACK, // ... 省略其他 } export const ErrorTypeStrings: Record<number | string, string> = { // 省略其他 [LifecycleHooks.RENDER_TRACKED]: 'renderTracked hook', [LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook', [ErrorCodes.SETUP_FUNCTION]: 'setup function', [ErrorCodes.RENDER_FUNCTION]: 'render function', // 省略其他 [ErrorCodes.SCHEDULER]: 'scheduler flush. This is likely a Vue internals bug. ' + 'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue-next' }当不同错误情况,根据错误码 ErrorCodes来获取 ErrorTypeStrings错误信息进行提示:// packages/runtime-core/src/errorHandling.ts function logError( err: unknown, type: ErrorTypes, contextVNode: VNode | null, throwInDev = true ) { if (__DEV__) { const info = ErrorTypeStrings[type] warn(`Unhandled error${info ? ` during execution of ${info}` : ``}`) // 省略其他 } else { console.error(err) } }6. 实现 Tree Shaking关于 Vue3 实现 Tree Shaking 的介绍,可以看我之前写的高效实现框架和 JS 库瘦身。 其中,logError 方法中就使用到了:// packages/runtime-core/src/errorHandling.ts function logError( err: unknown, type: ErrorTypes, contextVNode: VNode | null, throwInDev = true ) { if (__DEV__) { // 省略其他 } else { console.error(err) } }当编译成 production 环境后,__DEV__分支的代码不会被打包进去,从而优化包的体积。四、总结到上面一部分,我们就差不多搞清楚 Vue3 中全局异常处理的核心逻辑了。我们在开发自己的错误处理方法时,也可以考虑这几个核心点:支持同步和异步的异常处理;设置业务错误码、业务错误信息;支持自定义错误处理方法;支持开发环境错误提示;支持 Tree Shaking。
在开发组件库或者插件,经常会需要区分多种环境构建,从而实现:提供各种体积版本:全量版、精简版、基础版等;提供各种环境版本:web 版、nodejs 版等等;提供各种规范版本:esm 规范版本、cjs 规范版本、UMD 规范版本等等。那么如何能够方便实现上面功能呢?这种场景就适合使用 Feature Flags,在构建过程中,通过开关的启用和关闭,对构建代码的过程进行动态设置,从而更好的实现 Tree Shaking。Tree Shaking 是一种通过消除最终文件中未使用的代码来优化体积的方法。本文会从 Vue 源码(版本号:3.0.11)中使用的 Feature Flags 进行构建的过程开始介绍,然后通过简单示例进行学习,最后介绍 rollup、webpack 和 Vite 中的实现。本文代码地址:github.com/pingan8787/…一、什么是 Feature FlagsFeature Flag(又名 Feature Toggle、Flip等)是一种允许控制线上功能开启或者关闭的方式,通常会采取配置文件的方式来控制。fex.baidu.com/blog/2014/0…可以理解为在代码中添加一个开关,当开关开启,则逻辑会执行下去,否则不会执行,通常代码表现形式为 if语句,举个简单示例:const flags = true; const test = () => flags && console.log('Hello Feature Flags');当 flags为 true则执行输出,否则不会。 现在我们想控制日志会不会输出,只需改变 flags的值即可,test方法逻辑不用修改。😺 可以将 Feature Flag 翻译成特性标志。二、Vue 源码实现 Feature Flags2.1 使用示例从上一节对特性标志的介绍后,大家应该对其有点理解,接下来从 Vue3 源码中看一个使用示例:// packages/compiler-core/src/errors.ts export function defaultOnWarn(msg: CompilerError) { __DEV__ && console.warn(`[Vue warn] ${msg.message}`) }这里的 __DEV__就是一个 Feature Flag,当 __DEV__值为 true时,会输出后面的日志,否则不会输出。 在 Vue3 源码中还存在很多其他特性标志,比如:__COMMIT____TEST____GLOBAL__...还有很多,有兴趣的小伙伴可以在 Vue3 源码中找找。2.2 如何定义特性标志上面只是带大家看了下源码中如何使用,那么接下来看看__DEV__这些特性标志是如何定义的。 Vue3 中使用了 @rollup/replace依赖,实现构建时,替换文件中目标字符串内容,比如构建开发环境的包的过程中,将 __DEV__替换为 true。 还是以上面示例代码为例介绍:// 本地开发环境 __DEV__ 为 true,经过 @rollup/replace 依赖打包后如下: export function defaultOnWarn(msg: CompilerError) { true && console.warn(`[Vue warn] ${msg.message}`) } // 生成环境中 __DEV__ 为 false,经过 @rollup/replace 依赖打包后如下: export function defaultOnWarn(msg: CompilerError) { }构建后 defaultOnWarn方法内的 console.warn语句就被 Tree Shaking 移除掉了。三、上手 Feature Flags这一节通过将分别使用 rollup、webpack 和 Vite 实现三个 Feature Flags 的 Demo。其核心原理就是在构建阶段的时候,已经明确的 Feature Flags 值的内容会被替换成具体的值,然后进行 Tree Shaking。三个示例的全部代码可以到下面仓库查看:首先我们先创建一个 index.js文件,输入下面测试内容:// index.js const name = 'pingan8787'; const age = 18; const featureFlags = () => { console.warn(name) __DEV__ && console.log(name) } featureFlags();我们需要实现的目标是:当 __DEV__变量的值为 true 时,打包后的 index.js 将不包含 __DEV__ && console.log(name)这一行代码。 那么开始看看如何实现:3.1 rollup 实现在 rollup 中,需要使用@rollup/replace包实现构建时替换文本,我们先安装它:npm install @rollup/plugin-replace --save-dev然后在 rollup.config.js中使用:import replace from '@rollup/plugin-replace'; export default { input: 'index.js', output: { file: './dist/index.js', format: 'cjs' }, plugins: [ replace({ __DEV__: true }) ] };接下来通过 rollup打包命令,可以看到输出内容如下:const name = 'pingan8787'; const age = 18; const featureFlags = () => { console.warn(name) __DEV__ && console.log(name) } featureFlags();可以看到 __DEV__为 true时代码并没有 Tree Shaking,再试试改成 false,输出如下:'use strict'; const name = 'pingan8787'; const featureFlags = () => { console.warn(name); }; featureFlags();这边 __DEV__ && console.log(name)就被移除了,实现 Tree Shaking。 照着相同原理,再看看 webpack 和 Vite 的实现:3.2 webpack 实现webpack 中自带了 DefinePlugin可以实现该功能,具体可以看 DefinePlugin 文档 ,下面看看 webpack.config.js配置:// webpack.config.js const path = require('path') const webpack = require('webpack') module.exports = { entry: './index.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'index.js', }, mode: 'production', plugins: [ new webpack.DefinePlugin({ __DEV__: JSON.stringify(true), }) ], };因为这是使用 mode: 'production'模式,所以打包出来的代码会压缩:(()=>{const n="pingan8787";console.warn(n),console.log(n)})();可以看出 __DEV__已经不存在,但 console.log(n)还存在,这时候把 __DEV__改成 false再看看打包结果:console.warn("pingan8787");只剩下这句,其他都被 Tree Shaking 掉了。3.3 Vite 实现Vite 默认也是支持自定义全局变量,实现该功能,可以看文档define option。 通过 pnpm create vite创建一个简单 Vite 项目,并删除多余内容,并在 main.js中加入我们的测试代码:import { createApp } from 'vue' import App from './App.vue' const name = 'pingan8787'; const age = 18; const featureFlags = () => { console.warn(name) __DEV__ && console.log(name) } featureFlags(); createApp(App).mount('#app')并且在 vite.config.js中设置 __DEV__:// vite.config.js export default defineConfig({ plugins: [vue()], define: { __DEV__: true } }) 然后执行 pnpm build构建项目,可以看到压缩后的代码还存在 __DEV__ && console.log(name)。接下来修改 __DEV__的值为 false,再重新打包,可以看到代码已经被 Tree Shaking 了:3.4 注意如果遇到编辑器提示 __DEV__ 变量不存在的情况,可以新建一个类型声明文件来解决,比如新建一个 global.d.ts 文件来定义,内容如下:// Global compile-time constants declare var __DEV__: boolean到这里我们就使用 rollup、webpack 和 Vite 分别实现了一遍 Feature Flags 了。四、总结本文通过简单例子和 Vue3 源码,与大家介绍了 Feature Flags 的概念和简单的实现,最后分别使用 rollup、webpack 和 Vite 分别实现了一遍 Feature Flags 。在实际业务开发中,我们可以通过设计各种 Feature Flags,让代码能够更好的进行 Tree Shaking。
四、EXE-Components 组件库开发1. 组件库入口文件配置前面 package.json 文件中配置的 "build" 命令,会使用根目录下 index.js 作为入口文件,并且为了方便 components 通用基础组件和 modules 通用复杂组件的引入,我们创建 3 个 index.js,创建后目录结构如下: 三个入口文件内容分别如下:// EXE-Components/index.js import './components/index.js'; import './modules/index.js'; // EXE-Components/components/index.js import './exe-avatar/index.js'; import './exe-button/index.js'; // EXE-Components/modules/index.js import './exe-attachment-list/index.js.js'; import './exe-comment-footer/index.js.js'; import './exe-post-list/index.js.js'; import './exe-user-avatar/index.js';2. 开发 exe-avatar 组件 index.js 文件通过前面的分析,我们可以知道 exe-avatar组件需要支持参数:e-avatar-src:头像图片地址,例如:./testAssets/images/avatar-1.pnge-avatar-width:头像宽度,默认和高度一致,例如:52pxe-button-radius:头像圆角,例如:22px,默认:50%on-avatar-click:头像点击事件,默认无接着按照之前的模版,开发入口文件 index.js :// EXE-Components/components/exe-avatar/index.js import renderTemplate from './template.js'; import { Shared, Utils } from '../../utils/index.js'; const { getAttributes } = Shared; const { isStr, runFun } = Utils; const defaultConfig = { avatarWidth: "40px", avatarRadius: "50%", avatarSrc: "./assets/images/default_avatar.png", onAvatarClick: null, } const Selector = "exe-avatar"; export default class EXEAvatar extends HTMLElement { shadowRoot = null; config = defaultConfig; constructor(){ super(); this.render(); } render() { this.shadowRoot = this.attachShadow({mode: 'closed'}); this.shadowRoot.innerHTML = renderTemplate(this.config);// 生成 HTML 模版内容 } // 生命周期:当 custom element首次被插入文档DOM时,被调用。 connectedCallback() { this.updateStyle(); this.initEventListen(); } updateStyle() { this.config = {...defaultConfig, ...getAttributes(this)}; this.shadowRoot.innerHTML = renderTemplate(this.config); // 生成 HTML 模版内容 } initEventListen() { const { onAvatarClick } = this.config; if(isStr(onAvatarClick)){ // 判断是否为字符串 this.addEventListener('click', e => runFun(e, onAvatarClick)); } } } if (!customElements.get(Selector)) { customElements.define(Selector, EXEAvatar) } 其中有几个方法是抽取出来的公用方法,大概介绍下其作用,具体可以看源码:renderTemplate 方法来自 template.js 暴露的方法,传入配置 config,来生成 HTML 模版。getAttributes 方法传入一个 HTMLElement 元素,返回该元素上所有属性键值对,其中会对 e- 和 on- 开头的属性,分别处理成普通属性和事件属性,示例如下:// input <exe-avatar e-avatar-src="./testAssets/images/avatar-1.png" e-avatar-width="52px" e-avatar-radius="22px" on-avatar-click="avatarClick()" ></exe-avatar> // output { avatarSrc: "./testAssets/images/avatar-1.png", avatarWidth: "52px", avatarRadius: "22px", avatarClick: "avatarClick()" }runFun方法由于通过属性传递进来的方法,是个字符串,所以进行封装,传入 event 和事件名称作为参数,调用该方法,示例和上一步一样,会执行 avatarClick() 方法。另外,Web Components 生命周期可以详细看文档:使用生命周期回调函数。3. 开发 exe-avatar 组件 template.js 文件该文件暴露一个方法,返回组件 HTML 模版:// EXE-Components/components/exe-avatar/template.js export default config => { const { avatarWidth, avatarRadius, avatarSrc } = config; return ` <style> .exe-avatar { width: ${avatarWidth}; height: ${avatarWidth}; display: inline-block; cursor: pointer; } .exe-avatar .img { width: 100%; height: 100%; border-radius: ${avatarRadius}; border: 1px solid #efe7e7; } </style> <div class="exe-avatar"> <img class="img" src="${avatarSrc}" /> </div> ` }最终实现效果如下: 开发完第一个组件,我们可以简单总结一下创建和使用组件的步骤: 4. 开发 exe-button 组件按照前面 exe-avatar组件开发思路,可以很快实现 exe-button 组件。 需要支持下面参数:e-button-radius:按钮圆角,例如:8pxe-button-type:按钮类型,例如:default, primary, text, dashede-button-text:按钮文本,默认:打开on-button-click:按钮点击事件,默认无// EXE-Components/components/exe-button/index.js import renderTemplate from './template.js'; import { Shared, Utils } from '../../utils/index.js'; const { getAttributes } = Shared; const { isStr, runFun } = Utils; const defaultConfig = { buttonRadius: "6px", buttonPrimary: "default", buttonText: "打开", disableButton: false, onButtonClick: null, } const Selector = "exe-button"; export default class EXEButton extends HTMLElement { // 指定观察到的属性变化,attributeChangedCallback 会起作用 static get observedAttributes() { return ['e-button-type','e-button-text', 'buttonType', 'buttonText'] } shadowRoot = null; config = defaultConfig; constructor(){ super(); this.render(); } render() { this.shadowRoot = this.attachShadow({mode: 'closed'}); } connectedCallback() { this.updateStyle(); this.initEventListen(); } attributeChangedCallback (name, oldValue, newValue) { // console.log('属性变化', name) } updateStyle() { this.config = {...defaultConfig, ...getAttributes(this)}; this.shadowRoot.innerHTML = renderTemplate(this.config); } initEventListen() { const { onButtonClick } = this.config; if(isStr(onButtonClick)){ const canClick = !this.disabled && !this.loading this.addEventListener('click', e => canClick && runFun(e, onButtonClick)); } } get disabled () { return this.getAttribute('disabled') !== null; } get type () { return this.getAttribute('type') !== null; } get loading () { return this.getAttribute('loading') !== null; } } if (!customElements.get(Selector)) { customElements.define(Selector, EXEButton) } 模版定义如下:// EXE-Components/components/exe-button/tempalte.js // 按钮边框类型 const borderStyle = { solid: 'solid', dashed: 'dashed' }; // 按钮类型 const buttonTypeMap = { default: { textColor: '#222', bgColor: '#FFF', borderColor: '#222'}, primary: { textColor: '#FFF', bgColor: '#5FCE79', borderColor: '#5FCE79'}, text: { textColor: '#222', bgColor: '#FFF', borderColor: '#FFF'}, } export default config => { const { buttonRadius, buttonText, buttonType } = config; const borderStyleCSS = buttonType && borderStyle[buttonType] ? borderStyle[buttonType] : borderStyle['solid']; const backgroundCSS = buttonType && buttonTypeMap[buttonType] ? buttonTypeMap[buttonType] : buttonTypeMap['default']; return ` <style> .exe-button { border: 1px ${borderStyleCSS} ${backgroundCSS.borderColor}; color: ${backgroundCSS.textColor}; background-color: ${backgroundCSS.bgColor}; font-size: 12px; text-align: center; padding: 4px 10px; border-radius: ${buttonRadius}; cursor: pointer; display: inline-block; height: 28px; } :host([disabled]) .exe-button{ cursor: not-allowed; pointer-events: all; border: 1px solid #D6D6D6; color: #ABABAB; background-color: #EEE; } :host([loading]) .exe-button{ cursor: not-allowed; pointer-events: all; border: 1px solid #D6D6D6; color: #ABABAB; background-color: #F9F9F9; } </style> <button class="exe-button">${buttonText}</button> ` }最终效果如下: 5. 开发 exe-user-avatar 组件该组件是将前面 exe-avatar 组件和 exe-button 组件进行组合,不仅需要支持点击事件,还需要支持插槽 slot 功能。由于是做组合,所以开发起来比较简单~先看看入口文件:// EXE-Components/modules/exe-user-avatar/index.js import renderTemplate from './template.js'; import { Shared, Utils } from '../../utils/index.js'; const { getAttributes } = Shared; const { isStr, runFun } = Utils; const defaultConfig = { userName: "", subName: "", disableButton: false, onAvatarClick: null, onButtonClick: null, } export default class EXEUserAvatar extends HTMLElement { shadowRoot = null; config = defaultConfig; constructor() { super(); this.render(); } render() { this.shadowRoot = this.attachShadow({mode: 'open'}); } connectedCallback() { this.updateStyle(); this.initEventListen(); } initEventListen() { const { onAvatarClick } = this.config; if(isStr(onAvatarClick)){ this.addEventListener('click', e => runFun(e, onAvatarClick)); } } updateStyle() { this.config = {...defaultConfig, ...getAttributes(this)}; this.shadowRoot.innerHTML = renderTemplate(this.config); } } if (!customElements.get('exe-user-avatar')) { customElements.define('exe-user-avatar', EXEUserAvatar) }主要内容在 template.js 中:// EXE-Components/modules/exe-user-avatar/template.js import { Shared } from '../../utils/index.js'; const { renderAttrStr } = Shared; export default config => { const { userName, avatarWidth, avatarRadius, buttonRadius, avatarSrc, buttonType = 'primary', subName, buttonText, disableButton, onAvatarClick, onButtonClick } = config; return ` <style> :host{ color: "green"; font-size: "30px"; } .exe-user-avatar { display: flex; margin: 4px 0; } .exe-user-avatar-text { font-size: 14px; flex: 1; } .exe-user-avatar-text .text { color: #666; } .exe-user-avatar-text .text span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; overflow: hidden; } exe-avatar { margin-right: 12px; width: ${avatarWidth}; } exe-button { width: 60px; display: flex; justify-content: end; } </style> <div class="exe-user-avatar"> <exe-avatar ${renderAttrStr({ 'e-avatar-width': avatarWidth, 'e-avatar-radius': avatarRadius, 'e-avatar-src': avatarSrc, })} ></exe-avatar> <div class="exe-user-avatar-text"> <div class="name"> <span class="name-text">${userName}</span> <span class="user-attach"> <slot name="name-slot"></slot> </span> </div> <div class="text"> <span class="name">${subName}<slot name="sub-name-slot"></slot></span> </div> </div> ${ !disableButton && `<exe-button ${renderAttrStr({ 'e-button-radius' : buttonRadius, 'e-button-type' : buttonType, 'e-button-text' : buttonText, 'on-avatar-click' : onAvatarClick, 'on-button-click' : onButtonClick, })} ></exe-button>` } </div> ` }其中 renderAttrStr 方法接收一个属性对象,返回其键值对字符串:// input { 'e-avatar-width': 100, 'e-avatar-radius': 50, 'e-avatar-src': './testAssets/images/avatar-1.png', } // output "e-avatar-width='100' e-avatar-radius='50' e-avatar-src='./testAssets/images/avatar-1.png' "最终效果如下: 6. 实现一个用户列表业务接下来我们通过一个实际业务,来看看我们组件的效果: 其实实现也很简单,根据给定数据,然后循环使用组件即可,假设有以下用户数据:const users = [ {"name":"前端早早聊","desc":"帮 5000 个前端先跑 @ 前端早早聊","level":6,"avatar":"qdzzl.jpg","home":"https://juejin.cn/user/712139234347565"} {"name":"来自拉夫德鲁的码农","desc":"谁都不救我,谁都救不了我,就像我救不了任何人一样","level":2,"avatar":"lzlfdldmn.jpg","home":"https://juejin.cn/user/994371074524862"} {"name":"黑色的枫","desc":"永远怀着一颗学徒的心。。。","level":3,"avatar":"hsdf.jpg","home":"https://juejin.cn/user/2365804756348103"} {"name":"captain_p","desc":"目的地很美好,路上的风景也很好。今天增长见识了吗","level":2,"avatar":"cap.jpg","home":"https://juejin.cn/user/2532902235026439"} {"name":"CUGGZ","desc":"文章联系微信授权转载。微信:CUG-GZ,添加好友一起学习~","level":5,"avatar":"cuggz.jpg","home":"https://juejin.cn/user/3544481220801815"} {"name":"政采云前端团队","desc":"政采云前端 ZooTeam 团队,不掺水的原创。 团队站点:https://zoo.team","level":6,"avatar":"zcy.jpg","home":"https://juejin.cn/user/3456520257288974"} ]我们就可以通过简单 for 循环拼接 HTML 片段,然后添加到页面某个元素中:// 测试生成用户列表模版 const usersTemp = () => { let temp = '', code = ''; users.forEach(item => { const {name, desc, level, avatar, home} = item; temp += ` <exe-user-avatar e-user-name="${name}" e-sub-name="${desc}" e-avatar-src="./testAssets/images/users/${avatar}" e-avatar-width="36px" e-button-type="primary" e-button-text="关注" on-avatar-click="toUserHome('${home}')" on-button-click="toUserFollow('${name}')" > ${ level >= 0 && `<span slot="name-slot"> <span class="medal-item">(Lv${level})</span> </span>`} </exe-user-avatar> ` }) return temp; } document.querySelector('#app').innerHTML = usersTemp;到这边我们就实现了一个用户列表的业务,当然实际业务可能会更加复杂,需要再优化。五、总结本文首先简单回顾 Web Components 核心 API,然后对组件库需求进行分析设计,再进行环境搭建和开发,内容比较多,可能没有每一点都讲到,还请大家看看我仓库的源码,有什么问题欢迎和我讨论。写本文的几个核心目的:当我们接到一个新任务的时候,需要从分析设计开始,再到开发,而不是盲目一上来就开始开发;带大家一起看看如何用 Web Components 开发简单的业务组件库;体验一下 Web Components 开发组件库有什么缺点(就是要写的东西太多了)。最后看完本文,大家是否觉得用 Web Components 开发组件库,实在有点复杂?要写的太多了。 没关系,下一篇我将带大家一起使用 Stencil 框架开发 Web Components 标准的组件库,毕竟整个 ionic 已经是使用 Stencil 重构,Web Components 大势所趋~!拓展阅读WEBCOMPONENTS.ORG Discuss & share web componentsWeb Components as TechnologyStenciljs - Build. Customize. Distribute. Adopt.
组件化是前端发展的一个重要方向,它一方面提高开发效率,另一方面降低维护成本。主流的 Vue.js、React 及其延伸的 Ant Design、uniapp、Taro 等都是组件框架。Web Components 是一组 Web 原生 API 的总称,允许我们创建可重用的自定义组件,并在我们 Web 应用中像使用原生 HTML 标签一样使用。目前已经很多前端框架/库支持 Web Components。本文将带大家回顾 Web Components 核心 API,并从 0 到 1 实现一个基于 Web Components API 开发的业务组件库。最终效果:blog.pingan8787.com/exe-compone… 仓库地址:github.com/pingan8787/…一、回顾 Web Components在前端发展历史中,从刚开始重复业务到处复制相同代码,到 Web Components 的出现,我们使用原生 HTML 标签的自定义组件,复用组件代码,提高开发效率。通过 Web Components 创建的组件,几乎可以使用在任何前端框架中。1. 核心 API 回顾Web Components 由 3 个核心 API 组成:Custom elements(自定义元素):用来让我们定义自定义元素及其行为,对外提供组件的标签;Shadow DOM(影子 DOM):用来封装组件内部的结构,避免与外部冲突;HTML templates(HTML 模版):包括 <template>和<slot> 元素,让我们可以定义各种组件的 HTML 模版,然后被复用到其他地方,使用过 Vue/React 等框架的同学应该会很熟悉。另外,还有 HTML imports,但目前已废弃,所以不具体介绍,其作用是用来控制组件的依赖加载。2. 入门示例接下来通过下面简单示例快速了解一下如何创建一个简单 Web Components 组件。使用组件<!DOCTYPE html> <html lang="en"> <head> <script src="./index.js" defer></script> </head> <body> <h1>custom-element-start</h1> <custom-element-start></custom-element-start> </body> </html>定义组件/** * 使用 CustomElementRegistry.define() 方法用来注册一个 custom element * 参数如下: * - 元素名称,符合 DOMString 规范,名称不能是单个单词,且必须用短横线隔开 * - 元素行为,必须是一个类 * - 继承元素,可选配置,一个包含 extends 属性的配置对象,指定创建的元素继承自哪个内置元素,可以继承任何内置元素。 */ class CustomElementStart extends HTMLElement { constructor(){ super(); this.render(); } render(){ const shadow = this.attachShadow({mode: 'open'}); const text = document.createElement("span"); text.textContent = 'Hi Custom Element!'; text.style = 'color: red'; shadow.append(text); } } customElements.define('custom-element-start', CustomElementStart)上面代码主要做 3 件事:实现组件类通过实现 CustomElementStart 类来定义组件。定义组件将组件的标签和组件类作为参数,通过 customElements.define 方法定义组件。使用组件导入组件后,跟使用普通 HTML 标签一样直接使用自定义组件 <custom-element-start></custom-element-start>。随后浏览器访问 index.html 可以看到下面内容: 3. 兼容性介绍在 MDN | Web Components 章节中介绍了其兼容性情况:Firefox(版本63)、Chrome和Opera都默认支持Web组件。Safari支持许多web组件特性,但比上述浏览器少。Edge正在开发一个实现。关于兼容性,可以看下图: 图片来源:www.webcomponents.org/这个网站里面,有很多关于 Web Components 的优秀项目可以学习。4. 小结这节主要通过一个简单示例,简单回顾基础知识,详细可以阅读文档:使用 custom elements使用 shadow DOM使用 templates and slots二、EXE-Components 组件库分析设计1. 背景介绍假设我们需要实现一个 EXE-Components 组件库,该组件库的组件分 2 大类:components 类型以通用简单组件为主,如exe-avatar头像组件、 exe-button按钮组件等;modules 类型以复杂、组合组件为主,如exe-user-avatar用户头像组件(含用户信息)、exe-attachement-list附件列表组件等等。详细可以看下图: 接下来我们会基于上图进行 EXE-Components 组件库设计和开发。2. 组件库设计在设计组件库的时候,主要需要考虑以下几点:组件命名、参数命名等规范,方便组件后续维护;组件参数定义;组件样式隔离;当然,这几个是最基础需要考虑的点,随着实际业务的复杂,还需要考虑更多,比如:工程化相关、组件解耦、组件主题等等。针对前面提到这 3 点,这边约定几个命名规范:组件名称以 exe-功能名称 进行命名,如 exe-avatar表示头像组件;属性参数名称以 e-参数名称 进行命名,如 e-src 表示 src 地址属性;事件参数名称以 on-事件类型 进行命名,如 on-click表示点击事件;3. 组件库组件设计这边我们主要设计 exe-avatar 、exe-button 和 exe-user-avatar三个组件,前两个为简单组件,后一个为复杂组件,其内部使用了前两个组件进行组合。这边先定义这三个组件支持的属性: 这边属性命名看着会比较复杂,大家可以按照自己和团队的习惯进行命名。这样我们思路就清晰很多,实现对应组件即可。三、EXE-Components 组件库准备工作本文示例最终将对实现的组件进行组合使用,实现下面「用户列表」效果: 体验地址:blog.pingan8787.com/exe-compone…1. 统一开发规范首先我们先统一开发规范,包括:目录规范定义组件规范组件开发模版组件开发模版分 index.js组件入口文件和 template.js 组件 HTML 模版文件:// index.js 模版 const defaultConfig = { // 组件默认配置 } const Selector = "exe-avatar"; // 组件标签名 export default class EXEAvatar extends HTMLElement { shadowRoot = null; config = defaultConfig; constructor(){ super(); this.render(); // 统一处理组件初始化逻辑 } render() { this.shadowRoot = this.attachShadow({mode: 'closed'}); this.shadowRoot.innerHTML = renderTemplate(this.config); } } // 定义组件 if (!customElements.get(Selector)) { customElements.define(Selector, EXEAvatar) }// template.js 模版 export default config => { // 统一读取配置 const { avatarWidth, avatarRadius, avatarSrc } = config; return ` <style> /* CSS 内容 */ </style> <div class="exe-avatar"> /* HTML 内容 */ </div> ` }2. 开发环境搭建和工程化处理为了方便使用 EXE-Components 组件库,更接近实际组件库的使用,我们需要将组件库打包成一个 UMD 类型的 js 文件。这边我们使用 rollup 进行构建,最终打包成 exe-components.js 的文件,使用方式如下:<script src="./exe-components.js"></script>接下来通过 npm init -y生成 package.json文件,然后全局安装 rollup 和 http-server(用来启动本地服务器,方便调试):npm init -y npm install --global rollup http-server然后在 package.json的 script 下添加 "dev"和 "build"脚本:{ // ... "scripts": { "dev": "http-server -c-1 -p 1400", "build": "rollup index.js --file exe-components.js --format iife" }, }其中:"dev" 命令:通过 http-server 启动静态服务器,作为开发环境使用。添加 -c-1 参数用来禁用缓存,避免刷新页面还会有缓存,详细可以看 http-server 文档;"build"命令:将 index.js 作为 rollup 打包的入口文件,输出 exe-components.js 文件,并且是 iife 类型的文件。这样就完成简单的本地开发和组件库构建的工程化配置,接下来就可以进行开发了。
三、源码分析克隆 cacheables 项目下来后,可以看到主要逻辑都在 index.ts中,去掉换行和注释,代码量 200 行左右,阅读起来比较简单。 接下来我们按照官方提供的示例,作为主线来阅读源码。1. 创建缓存实例示例中第 ① 步中,先通过 new Cacheables()创建一个缓存实例,在源码中Cacheables类的定义如下,这边先删掉多余代码,看下类提供的方法和作用:export class Cacheables { constructor(options?: CacheOptions) { this.enabled = options?.enabled ?? true; this.log = options?.log ?? false; this.logTiming = options?.logTiming ?? false; } // 使用提供的参数创建一个 key static key(): string {} // 删除一笔缓存 delete(): void {} // 清除所有缓存 clear(): void {} // 返回指定 key 的缓存对象是否存在,并且有效(即是否超时) isCached(key: string): boolean {} // 返回所有的缓存 key keys(): string[] {} // 用来包装方法调用,做缓存 async cacheable<T>(): Promise<T> {} }这样就很直观清楚 cacheables 实例的作用和支持的方法,其 UML 类图如下:在第 ① 步实例化时,Cacheables 内部构造函数会将入参保存起来,接口定义如下:const cache = new Cacheables({ logTiming: true, log: true, }); export type CacheOptions = { // 缓存开关 enabled?: boolean; // 启用/禁用缓存命中日志 log?: boolean; // 启用/禁用计时 logTiming?: boolean; };根据参数可以看出,此时我们 Cacheables 实例支持缓存日志和计时功能。2. 包装缓存方法第 ② 步中,我们将请求方法包装在 cache.cacheable方法中,实现使用 max-age作为缓存策略,并且有效期 5000 毫秒的缓存:const getWeatherData = () => cache.cacheable(() => fetch(apiUrl), "weather", { cachePolicy: "max-age", maxAge: 5000, });其中,cacheable 方法是 Cacheables类上的成员方法,定义如下(移除日志相关代码):// 执行缓存设置 async cacheable<T>( resource: () => Promise<T>, // 一个返回Promise的函数 key: string, // 缓存的 key options?: CacheableOptions, // 缓存策略 ): Promise<T> { const shouldCache = this.enabled // 没有启用缓存,则直接调用传入的函数,并返回调用结果 if (!shouldCache) { return resource() } // ... 省略日志代码 const result = await this.#cacheable(resource, key, options) // 核心 // ... 省略日志代码 return result }其中cacheable 方法接收三个参数:resource:需要包装的函数,是一个返回 Promise 的函数,如 () => fetch();key:用来做缓存的 key;options:缓存策略的配置选项;返回 this.#cacheable私有方法执行的结果,this.#cacheable私有方法实现如下:// 处理缓存,如保存缓存对象等 async #cacheable<T>( resource: () => Promise<T>, key: string, options?: CacheableOptions, ): Promise<T> { // 先通过 key 获取缓存对象 let cacheable = this.#cacheables[key] as Cacheable<T> | undefined // 如果不存在该 key 下的缓存对象,则通过 Cacheable 实例化一个新的缓存对象 // 并保存在该 key 下 if (!cacheable) { cacheable = new Cacheable() this.#cacheables[key] = cacheable } // 调用对应缓存策略 return await cacheable.touch(resource, options) }this.#cacheable私有方法接收的参数与 cacheable方法一样,返回的是 cacheable.touch方法调用的结果。 如果 key 的缓存对象不存在,则通过 Cacheable类创建一个,其 UML 类图如下: 3. 处理缓存策略上一步中,会通过调用 cacheable.touch方法,来执行对应缓存策略,该方法定义如下:// 执行缓存策略的方法 async touch( resource: () => Promise<T>, options?: CacheableOptions, ): Promise<T> { if (!this.#initialized) { return this.#handlePreInit(resource, options) } if (!options) { return this.#handleCacheOnly() } // 通过实例化 Cacheables 时候配置的 options 的 cachePolicy 选择对应策略进行处理 switch (options.cachePolicy) { case 'cache-only': return this.#handleCacheOnly() case 'network-only': return this.#handleNetworkOnly(resource) case 'stale-while-revalidate': return this.#handleSwr(resource) case 'max-age': // 本案例使用的类型 return this.#handleMaxAge(resource, options.maxAge) case 'network-only-non-concurrent': return this.#handleNetworkOnlyNonConcurrent(resource) } }touch方法接收两个参数,来自 #cacheable私有方法参数的 resource和 options。 本案例使用的是 max-age缓存策略,所以我们看看对应的 #handleMaxAge私有方法定义(其他的类似):// maxAge 缓存策略的处理方法 #handleMaxAge(resource: () => Promise<T>, maxAge: number) { // #lastFetch 最后发送时间,在 fetch 时会记录当前时间 // 如果当前时间大于 #lastFetch + maxAge 时,会非并发调用传入的方法 if (!this.#lastFetch || Date.now() > this.#lastFetch + maxAge) { return this.#fetchNonConcurrent(resource) } return this.#value // 如果是缓存期间,则直接返回前面缓存的结果 }当我们第二次执行 getWeatherData() 已经是 6 秒后,已经超过 maxAge设置的 5 秒,所有之后就会缓存失效,重新发请求。再看下 #fetchNonConcurrent私有方法定义,该方法用来发送非并发的请求:// 发送非并发请求 async #fetchNonConcurrent(resource: () => Promise<T>): Promise<T> { // 非并发情况,如果当前请求还在发送中,则直接执行当前执行中的方法,并返回结果 if (this.#isFetching(this.#promise)) { await this.#promise return this.#value } // 否则直接执行传入的方法 return this.#fetch(resource) }#fetchNonConcurrent私有方法只接收参数 resource,即需要包装的函数。这边先判断当前是否是【发送中】状态,如果则直接调用 this.#promise,并返回缓存的值,结束调用。否则将 resource 传入 #fetch执行。#fetch私有方法定义如下:// 执行请求发送 async #fetch(resource: () => Promise<T>): Promise<T> { this.#lastFetch = Date.now() this.#promise = resource() // 定义守卫变量,表示当前有任务在执行 this.#value = await this.#promise if (!this.#initialized) this.#initialized = true this.#promise = undefined // 执行完成,清空守卫变量 return this.#value } #fetch 私有方法接收前面的需要包装的函数,并通过对守卫变量赋值,控制任务的执行,在刚开始执行时进行赋值,任务执行完成以后,清空守卫变量。这也是我们实际业务开发经常用到的方法,比如发请求前,通过一个变量赋值,表示当前有任务执行,不能在发其他请求,在请求结束后,将该变量清空,继续执行其他任务。完成任务。「cacheables」执行过程大致是这样,接下来我们总结一个通用的缓存方案,便于理解和拓展。四、通用缓存库设计方案在 Cacheables 中支持五种缓存策略,上面只介绍其中的 max-age:这里总结一套通用缓存库设计方案,大致如下图:该缓存库支持实例化是传入 options参数,将用户传入的 options.key作为 key,调用CachePolicyHandler对象中获取用户指定的缓存策略(Cache Policy)。 然后将用户传入的 options.resource作为实际要执行的方法,通过 CachePlicyHandler()方法传入并执行。上图中,我们需要定义各种缓存库操作方法(如读取、设置缓存的方法)和各种缓存策略的处理方法。当然也可以集成如 Logger等辅助工具,方便用户使用和开发。本文就不在赘述,核心还是介绍这个方案。五、总结本文与大家分享 cacheables 缓存库源码核心逻辑,其源码逻辑并不复杂,主要便是支持各种缓存策略和对应的处理逻辑。文章最后和大家归纳一种通用缓存库设计方案,大家有兴趣可以自己实战试试,好记性不如烂笔头。 思路最重要,这种思路可以运用在很多场景,大家可以在实际业务中多多练习和总结。六、还有几点思考1. 思考读源码的方法大家都在读源码,讨论源码,那如何读源码? 个人建议:先确定自己要学源码的部分(如 Vue2 响应式原理、Vue3 Ref 等);根据要学的部分,写个简单 demo;通过 demo 断点进行大致了解;翻阅源码,详细阅读,因为源码中往往会有注释和示例等。如果你只是单纯想开始学某个库,可以先阅读 README.md,重点开介绍、特点、使用方法、示例等。抓住其特点、示例进行针对性的源码阅读。 相信这样阅读起来,思路会更清晰。2. 思考面向接口编程这个库使用了 TypeScript,通过每个接口定义,我们能很清晰的知道每个类、方法、属性作用。这也是我们需要学习的。 在我们接到需求任务时,可以这样做,你的效率往往会提高很多:功能分析:对整个需求进行分析,了解需要实现的功能和细节,通过 xmind 等工具进行梳理,避免做着做着,经常返工,并且代码结构混乱。功能设计:梳理完需求后,可以对每个部分进行设计,如抽取通用方法等,功能实现:前两步都做好,相信功能实现已经不是什么难度了~3. 思考这个库的优化点这个库代码主要集中在 index.ts中,阅读起来还好,当代码量增多后,恐怕阅读体验比较不好。 所以我的建议是:对代码进行拆分,将一些独立的逻辑拆到单独文件维护,比如每个缓存策略的逻辑,可以单独一个文件,通过统一开发方式开发(如 Plugin),再统一入口文件导入和导出。可以将 Logger这类内部工具方法改造成支持用户自定义,比如可以使用其他日志工具方法,不一定使用内置 Logger,更加解耦。可以参考插件化架构设计,这样这个库会更加灵活可拓展。
这是我参与11月更文挑战的第 1 天,活动详情查看:2021最后一次更文挑战这两天用到 cacheables 缓存库,觉得挺不错的,和大家分享一下我看完源码的总结。推荐下另外几篇:如何优雅的在微信小程序使用 SVG 字体图标如何优雅的管理 HTTP 请求和响应拦截器?探索 Snabbdom 模块系统原理探索 Vue.js 响应式原理一、介绍「cacheables」正如它名字一样,是用来做内存缓存使用,其代码仅仅 200 行左右(不含注释),官方的介绍如下: 一个简单的内存缓存,支持不同的缓存策略,使用 TypeScript 编写优雅的语法。它的特点:优雅的语法,包装现有 API 调用,节省 API 调用;完全输入的结果。不需要类型转换。支持不同的缓存策略。集成日志:检查 API 调用的时间。使用辅助函数来构建缓存 key。适用于浏览器和 Node.js。没有依赖。进行大范围测试。体积小,gzip 之后 1.43kb。当我们业务中需要对请求等异步任务做缓存,避免重复请求时,完全可以使用上「cacheables」。二、上手体验上手 cacheables很简单,看看下面使用对比:// 没有使用缓存 fetch("https://some-url.com/api"); // 有使用缓存 cache.cacheable(() => fetch("https://some-url.com/api"), "key");接下来看下官网提供的缓存请求的使用示例:1. 安装依赖npm install cacheables // 或者 pnpm add cacheables2. 使用示例import { Cacheables } from "cacheables"; const apiUrl = "http://localhost:3000/"; // 创建一个新的缓存实例 ① const cache = new Cacheables({ logTiming: true, log: true, }); // 模拟异步任务 const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // 包装一个现有 API 调用 fetch(apiUrl),并分配一个 key 为 weather // 下面例子使用 'max-age' 缓存策略,它会在一段时间后缓存失效 // 该方法返回一个完整 Promise,就像' fetch(apiUrl) '一样,可以缓存结果。 const getWeatherData = () => // ② cache.cacheable(() => fetch(apiUrl), "weather", { cachePolicy: "max-age", maxAge: 5000, }); const start = async () => { // 获取新数据,并添加到缓存中 const weatherData = await getWeatherData(); // 3秒之后再执行 await wait(3000); // 缓存新数据,maxAge设置5秒,此时还未过期 const cachedWeatherData = await getWeatherData(); // 3秒之后再执行 await wait(3000); // 缓存超过5秒,此时已过期,此时请求的数据将会再缓存起来 const freshWeatherData = await getWeatherData(); }; start(); 复制代码上面示例代码我们就实现一个请求缓存的业务,在 maxAge为 5 秒内的重复请求,不会重新发送请求,而是从缓存读取其结果进行返回。3. API 介绍官方文档中介绍了很多 API,具体可以从文档中获取,比较常用的如 cache.cacheable(),用来包装一个方法进行缓存。 所有 API 如下:new Cacheables(options?): Cacheablescache.cacheable(resource, key, options?): Promise<T>cache.delete(key: string): voidcache.clear(): voidcache.keys(): string[]cache.isCached(key: string): booleanCacheables.key(...args: (string | number)[]): string可以通过下图加深理解:
最近重构一个老项目,发现其中处理请求的拦截器写得相当乱,于是我将整个项目的请求处理层重构了,目前已经在项目中正常运行。本文会和大家分享我的重构思路和后续优化的思考,为方便与大家分享,我用 Vue3 实现一个简单 demo,思路是一致的,有兴趣的朋友可以在我 Github 查看,本文会以这个 Vue 实现的 demo 为例介绍。本文我会主要和大家分享以下几点:问题分析和方案设计;重构后效果;开发过程;后期优化点;如果你还不清楚什么是 HTTP 请求和响应拦截器,那么可以先看看《77.9K Star 的 Axios 项目有哪些值得借鉴的地方》 。一、需求思考和方案设计1. 问题分析目前旧项目经过多位同事参与开发,拦截器存在以下问题:代码比较混乱,可读性差;每个拦截器职责混乱,存在相互依赖;逻辑上存在问题;团队内部不同项目无法复用;2. 方案设计分析上面问题后,我初步的方案如下: 参考插件化架构设计,独立每个拦截器,将每个拦截器抽离成单独文件维护,做到职责单一,然后通过拦截器调度器进行调度和注册。其拦截器调度过程如下图:二、重构后效果代码其实比较简单,这里先看下最后实现效果:1. 目录分层更加清晰重构后请求处理层的目录分层更加清晰,大致如下:2. 拦截器开发更加方便在后续业务拓展新的拦截器,仅需 3 个步骤既可以完成拦截器的开发和使用,拦截器调度器会自动调用所有拦截器:3. 每个拦截器职责更加单一,可插拔将每个拦截器抽成一个文件去实现,让每个拦截器职责分离且单一,当不需要使用某个拦截器时,随时可以替换,灵活插拔。三、开发过程这里以我单独抽出来的这个 demo 项目为例来介绍。1. 初始化目录结构按照前面设计的方案,首先需要在项目中创建一下目录结构:- request - index.js // 拦截器调度器 - interceptors - request // 用来存放每个请求拦截器 - index.js // 管理所有请求拦截器,并做排序 - response // 用来存放每个响应拦截器 - index.js // 管理所有响应拦截器,并做排序2. 定义拦截器调度器因为项目采用 axios 请求库,所以我们需要先知道 axios 拦截器的使用方法,这里简单看下 axios 文档上如何使用拦截器的:// 添加请求拦截器 axios.interceptors.request.use(function (config) { // 业务 逻辑 return config; }, function (error) { // 业务 逻辑 return Promise.reject(error); }); // 添加响应拦截器 axios.interceptors.response.use(function (response) { // 业务 逻辑 return response; }, function (error) { // 业务逻辑 return Promise.reject(error); });从上面代码,我们可以知道,使用拦截器的时候,只需调用 axios.interceptors 对象上对应方法即可,因此我们可以将这块逻辑抽取出来:// src/request/interceptors/index.js import { log } from '../log'; import request from './request/index'; import response from './response/index'; export const runInterceptors = instance => { log('[runInterceptors]', instance); if(!instance) return; // 设置请求拦截器 for (const key in request) { instance.interceptors.request .use(config => request[key](config)); } // 设置响应拦截器 for (const key in response) { instance.interceptors.response .use(result => response[key](result)); } return instance; }这就是我们的核心拦截器调度器,目前实现导入所有请求拦截器和响应拦截器后,通过 for 循环,注册所有拦截器,最后将整个 axios 实例返回出去。3. 定义简单的请求拦截器和响应拦截器这里我们做简单演示,创建以下两个拦截器:请求拦截器:setLoading,作用是在发起请求前,显示一个全局 Toast 框,提示“加载中...”文案。响应拦截器:setLoading,作用是在请求响应后,关闭页面中的 Toast 框。为了统一开发规范,我们约定插件开发规范如下:/* 拦截器名称:xxx */ const interceptorName = options => { log("[interceptor.request]interceptorName:", options); // 拦截器业务 return options; }; export default interceptorName;首先创建文件 src/request/interceptors/request/ 目录下创建 setLoading.js 文件,按照上面约定的插件开发规范,我们完成下面插件开发:// src/request/interceptors/request/setLoading.js import { Toast } from 'vant'; import { log } from "../../log"; /* 拦截器名称:全局设置请求的 loading 动画 */ const setLoading = options => { log("[interceptor.request]setLoading:", options); Toast.loading({ duration: 0, message: '加载中...', forbidClick: true, }); return options; }; export default setLoading;然后在导出该请求拦截器,并且导出的是个数组,方便拦截器调度器进行统一注册:// src/request/interceptors/request/index.js import setLoading from './setLoading'; export default [ setLoading ];按照相同方式,我们开发响应拦截器:// src/request/interceptors/response/setLoading.js import { Toast } from 'vant'; import { log } from "../../log"; /* 拦截器名称:关闭全局请求的 loading 动画 */ const setLoading = result => { log("[interceptor.response]setLoading:", result); // example: 请求返回成功时,关闭所有 toast 框 if(result && result.success){ Toast.clear(); } return result; }; export default setLoading;导出响应拦截器:// src/request/interceptors/response/index.js import setLoading from './setLoading'; export default [ setLoading ];4. 全局设置 axios 拦截器按照前面相同步骤,我又多写了几个拦截器: 请求拦截器:setSecurityInformation.js:为请求的 url 添加安全参数;setSignature.js:为请求的请求头添加加签信息;setToken.js: 为请求的请求头添加 token 信息;响应拦截器:setError.js:处理响应结果的出错情况,如关闭所有 toast 框;setInvalid.js:处理响应结果的登录失效情况,如跳转到登录页;setResult.js:处理响应结果的数据嵌套太深的问题,将 result.data.data.data 这类返回结果处理成 result.data 格式;至于是如何实现的,大家有兴趣可以在我 Github 查看。然后我们可以将 axios 进行二次封装,导出 request 对象供业务使用:// src/request/index.js import axios from 'axios'; import { runInterceptors } from './interceptors/index'; export const requestConfig = { timeout: 10000 }; let request = axios.create(requestConfig); request = runInterceptors(request); export default request;到这边就完成。在业务中需要发起请求,可以这么使用:<template> <div><button @click="send">发起请求</button></div> </template> <script setup> import request from './../request/index.js'; const send = async () => { const result = await request({ url: 'https://httpbin.org/headers', method: 'get' }) } </script>5. 测试一下开发到这边就差不多,我们发送个请求,可以看到所有拦截器执行过程如下:看看请求头信息:可以看到我们开发的请求拦截器已经生效。四、Taro 中使用由于 Taro 中已经提供了 Taro.request 方法作为请求方法,我们可以不需要使用 axios 发请求。基于上面代码进行改造,也很简单,只需要更改 2 个地方:1. 修改封装请求的方法主要是更换 axios 为 Taro.request 方法,并使用 addInterceptor 方法导入拦截器:// src/request/index.js import Taro from "@tarojs/taro"; import { runInterceptors } from './interceptors/index'; Taro.addInterceptor(runInterceptors); export const request = Taro.request; export const requestTask = Taro.RequestTask; // 看需求,是否需要 export const addInterceptor = Taro.addInterceptor; // 看需求,是否需要2. 修改拦截器调度器由于 axios 和 Taro.request 添加拦截器的方法不同,所以也需要进行更换:import request from './interceptors/request'; import response from './interceptors/response'; export const interceptor = { request, response }; export const getInterceptor = (chain = {}) => { // 设置请求拦截器 let requestParams = chain.requestParams; for (const key in request) { requestParams = request[key](requestParams); } // 设置响应拦截器 let responseObject = chain.proceed(requestParams); for (const key in response) { responseObject = responseObject.then(res => response[key](res)); } return responseObject; };具体 API 可以看 Taro.request 文档,这里不过多介绍。五、项目总结和思考这次重构主要是按照已有业务进行重构,因此即使是重构后的请求层,仍然还有很多可以优化的点,目前我想到有这些,也算是我的一个 TODO LIST 了:1. 将请求层独立成库由于公司现在独立站点的项目较多,考虑到项目的统一开发规范,可以考虑将该请求层独立为私有库进行维护。 目前思路:参考插件化架构设计,通过 lerna 做管理所有拦截器;升级 TypeScript,方便管理和开发;进行工程化改造,加入构建工具、单元测试、UMD等等;使用文档和开发文档完善。2. 支持可更换请求库单独抽这一点来讲,是因为目前我们前端团队使用的请求库较多,比较分散,所以考虑到通用性,需要增加支持可更换请求库方法。 目前思路:在已有请求层再抽象一层请求库适配层,定义统一接口;内置几种常见请求库的适配。3. 开发拦截器脚手架这个的目的其实很简单,让团队内其他人直接使用脚手架工具,按照内置脚手架模版,快速创建一个拦截器,进行后续开发,很大程度统一拦截器的开发规范。 目前思路:内置两套拦截器模版:请求拦截器和响应拦截器;脚手架开发比较简单,参数(如语言)根据业务需要再确定。4. 增强拦截器调度目前实现的这个功能还比较简单,还是得考虑增强拦截器调度。 目前思路:处理拦截器失败的情况;处理拦截器调度顺序的问题;拦截器同步执行、异步执行、并发执行、循环执行等等情况;可插拔的拦截器调度;考虑参考 Tapable 插件机制;六、本文总结本文通过一次简单的项目重构总结出一个请求层拦截器调度方案,目的是为了实现所有拦截器职责单一、方便维护,并统一维护和自动调度,大大降低实际业务的拦截器开发上手难度。后续我仍有很多需要优化的地方,作为自己的一个 TODO LIST,如果是做成完全通用,则定位可能更偏向于拦截器调度容器,只提供一些通用拦截器,其余还是由开发者定义,库负责调度,但常用的请求库一般都已经做好,所以这样做的价值有待权衡。当然,目前还是优先作为团队内部私有库进行开发和使用,因为基本上团队内部使用的业务都差不多,只是项目不同。
Vue 3 中的响应式原理可谓是非常之重要,通过学习 Vue3 的响应式原理,不仅能让我们学习到 Vue.js 的一些设计模式和思想,还能帮助我们提高项目开发效率和代码调试能力。在这之前,我也写了一篇《探索 Vue.js 响应式原理》 ,主要介绍 Vue 2 响应式的原理,这篇补上 Vue 3 的。于是最近在 Vue Mastery 上重新学习 Vue3 Reactivity 的知识,这次收获更大。本文将带大家从头开始学习如何实现简单版 Vue 3 响应式,帮助大家了解其核心,后面阅读 Vue 3 响应式相关的源码能够更加得心应手。一、Vue 3 响应式使用1. Vue 3 中的使用当我们在学习 Vue 3 的时候,可以通过一个简单示例,看看什么是 Vue 3 中的响应式:<!-- HTML 内容 --> <div id="app"> <div>Price: {{price}}</div> <div>Total: {{price * quantity}}</div> <div>getTotal: {{getTotal}}</div> </div>const app = Vue.createApp({ // ① 创建 APP 实例 data() { return { price: 10, quantity: 2 } }, computed: { getTotal() { return this.price * this.quantity * 1.1 } } }) app.mount('#app') // ② 挂载 APP 实例通过创建 APP 实例和挂载 APP 实例即可,这时可以看到页面中分别显示对应数值: 当我们修改 price 或 quantity 值的时候,页面上引用它们的地方,内容也能正常展示变化后的结果。这时,我们会好奇为何数据发生变化后,相关的数据也会跟着变化,那么我们接着往下看。2. 实现单个值的响应式在普通 JS 代码执行中,并不会有响应式变化,比如在控制台执行下面代码:let price = 10, quantity = 2; const total = price * quantity; console.log(`total: ${total}`); // total: 20 price = 20; console.log(`total: ${total}`); // total: 20从这可以看出,在修改 price 变量的值后, total 的值并没有发生改变。那么如何修改上面代码,让 total 能够自动更新呢?我们其实可以将修改 total 值的方法保存起来,等到与 total 值相关的变量(如 price 或 quantity 变量的值)发生变化时,触发该方法,更新 total 即可。我们可以这么实现:let price = 10, quantity = 2, total = 0; const dep = new Set(); // ① const effect = () => { total = price * quantity }; const track = () => { dep.add(effect) }; // ② const trigger = () => { dep.forEach( effect => effect() )}; // ③ track(); console.log(`total: ${total}`); // total: 0 trigger(); console.log(`total: ${total}`); // total: 20 price = 20; trigger(); console.log(`total: ${total}`); // total: 40上面代码通过 3 个步骤,实现对 total 数据进行响应式变化:① 初始化一个 Set 类型的 dep 变量,用来存放需要执行的副作用( effect 函数),这边是修改 total 值的方法;② 创建 track() 函数,用来将需要执行的副作用保存到 dep 变量中(也称收集副作用);③ 创建 trigger() 函数,用来执行 dep 变量中的所有副作用;在每次修改 price 或 quantity 后,调用 trigger() 函数执行所有副作用后, total 值将自动更新为最新值。 (图片来源:Vue Mastery)3. 实现单个对象的响应式通常,我们的对象具有多个属性,并且每个属性都需要自己的 dep。我们如何存储这些?比如:let product = { price: 10, quantity: 2 };从前面介绍我们知道,我们将所有副作用保存在一个 Set 集合中,而该集合不会有重复项,这里我们引入一个 Map 类型集合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为前面保存副作用的 Set 集合(如: dep 对象),大致结构如下图: (图片来源:Vue Mastery)实现代码:let product = { price: 10, quantity: 2 }, total = 0; const depsMap = new Map(); // ① const effect = () => { total = product.price * product.quantity }; const track = key => { // ② let dep = depsMap.get(key); if(!dep) { depsMap.set(key, (dep = new Set())); } dep.add(effect); } const trigger = key => { // ③ let dep = depsMap.get(key); if(dep) { dep.forEach( effect => effect() ); } }; track('price'); console.log(`total: ${total}`); // total: 0 effect(); console.log(`total: ${total}`); // total: 20 product.price = 20; trigger('price'); console.log(`total: ${total}`); // total: 40上面代码通过 3 个步骤,实现对 total 数据进行响应式变化:① 初始化一个 Map 类型的 depsMap 变量,用来保存每个需要响应式变化的对象属性(key 为对象的属性, value 为前面 Set 集合);② 创建 track() 函数,用来将需要执行的副作用保存到 depsMap 变量中对应的对象属性下(也称收集副作用);③ 创建 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。4. 实现多个对象的响应式如果我们有多个响应式数据,比如同时需要观察对象 a 和对象 b 的数据,那么又要如何跟踪每个响应变化的对象?这里我们引入一个 WeakMap 类型的对象,将需要观察的对象作为 key ,值为前面用来保存对象属性的 Map 变量。代码如下:let product = { price: 10, quantity: 2 }, total = 0; const targetMap = new WeakMap(); // ① 初始化 targetMap,保存观察对象 const effect = () => { total = product.price * product.quantity }; const track = (target, key) => { // ② 收集依赖 let depsMap = targetMap.get(target); if(!depsMap){ targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if(!dep) { depsMap.set(key, (dep = new Set())); } dep.add(effect); } const trigger = (target, key) => { // ③ 执行指定对象的指定属性的所有副作用 const depsMap = targetMap.get(target); if(!depsMap) return; let dep = depsMap.get(key); if(dep) { dep.forEach( effect => effect() ); } }; track(product, 'price'); console.log(`total: ${total}`); // total: 0 effect(); console.log(`total: ${total}`); // total: 20 product.price = 20; trigger(product, 'price'); console.log(`total: ${total}`); // total: 40上面代码通过 3 个步骤,实现对 total 数据进行响应式变化:① 初始化一个 WeakMap 类型的 targetMap 变量,用来要观察每个响应式对象;② 创建 track() 函数,用来将需要执行的副作用保存到指定对象( target )的依赖中(也称收集副作用);③ 创建 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。大致流程如下图: (图片来源:Vue Mastery)二、Proxy 和 Reflect在上一节内容中,介绍了如何在数据发生变化后,自动更新数据,但存在的问题是,每次需要手动通过触发 track() 函数搜集依赖,通过 trigger() 函数执行所有副作用,达到数据更新目的。这一节将来解决这个问题,实现这两个函数自动调用。1. 如何实现自动操作这里我们引入 JS 对象访问器的概念,解决办法如下:在读取(GET 操作)数据时,自动执行 track() 函数自动收集依赖;在修改(SET 操作)数据时,自动执行 trigger() 函数执行所有副作用;那么如何拦截 GET 和 SET 操作?接下来看看 Vue2 和 Vue3 是如何实现的:在 Vue2 中,使用 ES5 的 Object.defineProperty() 函数实现;在 Vue3 中,使用 ES6 的 Proxy 和 Reflect API 实现;需要注意的是:Vue3 使用的 Proxy 和 Reflect API 并不支持 IE。Object.defineProperty() 函数这边就不多做介绍,可以阅读文档,下文将主要介绍 Proxy 和 Reflect API。2. 如何使用 Reflect通常我们有三种方法读取一个对象的属性:使用 . 操作符:leo.name ;使用 [] : leo['name'] ;使用 Reflect API: Reflect.get(leo, 'name') 。这三种方式输出结果相同。3. 如何使用 ProxyProxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。语法如下:const p = new Proxy(target, handler)参数如下:target : 要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。handler : 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。我们通过官方文档,体验一下 Proxy API:let product = { price: 10, quantity: 2 }; let proxiedProduct = new Proxy(product, { get(target, key){ console.log('正在读取的数据:',key); return target[key]; } }) console.log(proxiedProduct.price); // 正在读取的数据: price // 10这样就保证我们每次在读取 proxiedProduct.price 都会执行到其中代理的 get 处理函数。其过程如下: (图片来源:Vue Mastery)然后结合 Reflect 使用,只需修改 get 函数: get(target, key, receiver){ console.log('正在读取的数据:',key); return Reflect.get(target, key, receiver); }输出结果还是一样。接下来增加 set 函数,来拦截对象的修改操作:let product = { price: 10, quantity: 2 }; let proxiedProduct = new Proxy(product, { get(target, key, receiver){ console.log('正在读取的数据:',key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver){ console.log('正在修改的数据:', key, ',值为:', value); return Reflect.set(target, key, value, receiver); } }) proxiedProduct.price = 20; console.log(proxiedProduct.price); // 正在修改的数据: price ,值为: 20 // 正在读取的数据: price // 20 这样便完成 get 和 set 函数来拦截对象的读取和修改的操作。为了方便对比 Vue 3 源码,我们将上面代码抽象一层,使它看起来更像 Vue3 源码:function reactive(target){ const handler = { // ① 封装统一处理函数对象 get(target, key, receiver){ console.log('正在读取的数据:',key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver){ console.log('正在修改的数据:', key, ',值为:', value); return Reflect.set(target, key, value, receiver); } } return new Proxy(target, handler); // ② 统一调用 Proxy API } let product = reactive({price: 10, quantity: 2}); // ③ 将对象转换为响应式对象 product.price = 20; console.log(product.price); // 正在修改的数据: price ,值为: 20 // 正在读取的数据: price // 20这样输出结果仍然不变。4. 修改 track 和 trigger 函数通过上面代码,我们已经实现一个简单 reactive() 函数,用来将普通对象转换为响应式对象。但是还缺少自动执行 track() 函数和 trigger() 函数,接下来修改上面代码:const targetMap = new WeakMap(); let total = 0; const effect = () => { total = product.price * product.quantity }; const track = (target, key) => { let depsMap = targetMap.get(target); if(!depsMap){ targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if(!dep) { depsMap.set(key, (dep = new Set())); } dep.add(effect); } const trigger = (target, key) => { const depsMap = targetMap.get(target); if(!depsMap) return; let dep = depsMap.get(key); if(dep) { dep.forEach( effect => effect() ); } }; const reactive = (target) => { const handler = { get(target, key, receiver){ console.log('正在读取的数据:',key); const result = Reflect.get(target, key, receiver); track(target, key); // 自动调用 track 方法收集依赖 return result; }, set(target, key, value, receiver){ console.log('正在修改的数据:', key, ',值为:', value); const oldValue = target[key]; const result = Reflect.set(target, key, value, receiver); if(oldValue != result){ trigger(target, key); // 自动调用 trigger 方法执行依赖 } return result; } } return new Proxy(target, handler); } let product = reactive({price: 10, quantity: 2}); effect(); console.log(total); product.price = 20; console.log(total); // 正在读取的数据: price // 正在读取的数据: quantity // 20 // 正在修改的数据: price ,值为: 20 // 正在读取的数据: price // 正在读取的数据: quantity // 40 (图片来源:Vue Mastery)三、activeEffect 和 ref在上一节代码中,还存在一个问题: track 函数中的依赖( effect 函数)是外部定义的,当依赖发生变化, track 函数收集依赖时都要手动修改其依赖的方法名。比如现在的依赖为 foo 函数,就要修改 track 函数的逻辑,可能是这样:const foo = () => { /**/ }; const track = (target, key) => { // ② // ... dep.add(foo); }那么如何解决这个问题呢?1. 引入 activeEffect 变量接下来引入 activeEffect 变量,来保存当前运行的 effect 函数。let activeEffect = null; const effect = eff => { activeEffect = eff; // 1. 将 eff 函数赋值给 activeEffect activeEffect(); // 2. 执行 activeEffect activeEffect = null;// 3. 重置 activeEffect }然后在 track 函数中将 activeEffect 变量作为依赖:const track = (target, key) => { if (activeEffect) { // 1. 判断当前是否有 activeEffect let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, (dep = new Set())); } dep.add(activeEffect); // 2. 添加 activeEffect 依赖 } }使用方式修改为:effect(() => { total = product.price * product.quantity });这样就可以解决手动修改依赖的问题,这也是 Vue3 解决该问题的方法。完善一下测试代码后,如下:const targetMap = new WeakMap(); let activeEffect = null; // 引入 activeEffect 变量 const effect = eff => { activeEffect = eff; // 1. 将副作用赋值给 activeEffect activeEffect(); // 2. 执行 activeEffect activeEffect = null;// 3. 重置 activeEffect } const track = (target, key) => { if (activeEffect) { // 1. 判断当前是否有 activeEffect let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, (dep = new Set())); } dep.add(activeEffect); // 2. 添加 activeEffect 依赖 } } const trigger = (target, key) => { const depsMap = targetMap.get(target); if (!depsMap) return; let dep = depsMap.get(key); if (dep) { dep.forEach(effect => effect()); } }; const reactive = (target) => { const handler = { get(target, key, receiver) { const result = Reflect.get(target, key, receiver); track(target, key); return result; }, set(target, key, value, receiver) { const oldValue = target[key]; const result = Reflect.set(target, key, value, receiver); if (oldValue != result) { trigger(target, key); } return result; } } return new Proxy(target, handler); } let product = reactive({ price: 10, quantity: 2 }); let total = 0, salePrice = 0; // 修改 effect 使用方式,将副作用作为参数传给 effect 方法 effect(() => { total = product.price * product.quantity }); effect(() => { salePrice = product.price * 0.9 }); console.log(total, salePrice); // 20 9 product.quantity = 5; console.log(total, salePrice); // 50 9 product.price = 20; console.log(total, salePrice); // 100 18思考一下,如果把第一个 effect 函数中 product.price 换成 salePrice 会如何:effect(() => { total = salePrice * product.quantity }); effect(() => { salePrice = product.price * 0.9 }); console.log(total, salePrice); // 0 9 product.quantity = 5; console.log(total, salePrice); // 45 9 product.price = 20; console.log(total, salePrice); // 45 18得到的结果完全不同,因为 salePrice 并不是响应式变化,而是需要调用第二个 effect 函数才会变化,也就是 product.price 变量值发生变化。代码地址: github.com/Code-Pop/vu…2. 引入 ref 方法熟悉 Vue3 Composition API 的朋友可能会想到 Ref,它接收一个值,并返回一个响应式可变的 Ref 对象,其值可以通过 value 属性获取。ref:接受一个内部值并返回一个响应式且可变的 ref 对象。ref 对象具有指向内部值的单个 property .value。官网的使用示例如下:const count = ref(0) console.log(count.value) // 0 count.value++ console.log(count.value) // 1我们有 2 种方法实现 ref 函数:使用 rective 函数const ref = intialValue => reactive({value: intialValue});这样是可以的,虽然 Vue3 不是这么实现。使用对象的属性访问器(计算属性)属性方式去包括:getter 和 setter。const ref = raw => { const r = { get value(){ track(r, 'value'); return raw; }, set value(newVal){ raw = newVal; trigger(r, 'value'); } } return r; }使用方式如下:let product = reactive({ price: 10, quantity: 2 }); let total = 0, salePrice = ref(0); effect(() => { salePrice.value = product.price * 0.9 }); effect(() => { total = salePrice.value * product.quantity }); console.log(total, salePrice.value); // 18 9 product.quantity = 5; console.log(total, salePrice.value); // 45 9 product.price = 20; console.log(total, salePrice.value); // 90 18在 Vue3 中 ref 实现的核心也是如此。代码地址: github.com/Code-Pop/vu…四、实现简易 Computed 方法用过 Vue 的同学可能会好奇,上面的 salePrice 和 total 变量为什么不使用 computed 方法呢?没错,这个可以的,接下来一起实现个简单的 computed 方法。const computed = getter => { let result = ref(); effect(() => result.value = getter()); return result; } let product = reactive({ price: 10, quantity: 2 }); let salePrice = computed(() => { return product.price * 0.9; }) let total = computed(() => { return salePrice.value * product.quantity; }) console.log(total.value, salePrice.value); product.quantity = 5; console.log(total.value, salePrice.value); product.price = 20; console.log(total.value, salePrice.value);这里我们将一个函数作为参数传入 computed 方法,computed 方法内通过 ref 方法构建一个 ref 对象,然后通过 effct 方法,将 getter 方法返回值作为 computed 方法的返回值。这样我们实现了个简单的 computed 方法,执行效果和前面一样。五、源码学习建议1. 构建 reactivity.cjs.js这一节介绍如何去从 Vue 3 仓库打包一个 Reactivity 包来学习和使用。准备流程如下:从 Vue 3 仓库下载最新 Vue3 源码;git clone https://github.com/vuejs/vue-next.git安装依赖:yarn install构建 Reactivity 代码:yarn build reactivity复制 reactivity.cjs.js 到你的学习 demo 目录:上一步构建完的内容,会保存在 packages/reactivity/dist目录下,我们只要在自己的学习 demo 中引入该目录的 reactivity.cjs.js 文件即可。学习 demo 中引入:const { reactive, computed, effect } = require("./reactivity.cjs.js");2. Vue3 Reactivity 文件目录在源码的 packages/reactivity/src目录下,有以下几个主要文件:effect.ts:用来定义 effect / track / trigger ;baseHandlers.ts:定义 Proxy 处理器( get 和 set);reactive.ts:定义 reactive 方法并创建 ES6 Proxy;ref.ts:定义 reactive 的 ref 使用的对象访问器;computed.ts:定义计算属性的方法; (图片来源:Vue Mastery)六、总结本文带大家从头开始学习如何实现简单版 Vue 3 响应式,实现了 Vue3 Reactivity 中的核心方法( effect / track / trigger / computed /ref 等方法),帮助大家了解其核心,提高项目开发效率和代码调试能力。参考文章Vue Mastery往期推荐探索 React 合成事件探索 Vue.js 响应式原理探索 Snabbdom 模块系统原理
三、深入 Snabbdom 模块系统学习完前面这些基础知识后,我们已经知道 Snabbdom 使用方式,并且知道其中三个核心方法入参出参情况和大致作用,接下来开始看本文核心 Snabbdom 模块系统。1. Modules 介绍Snabbdom 模块系统是 Snabbdom 提供的一套可拓展、可灵活组合的模块系统,用来为 Snabbdom 提供操作 VNode 时的各种模块支持,如我们组建需要处理 style 则引入对应的 styleModule,需要处理事件,则引入 eventListenersModule 既可,这样就达到灵活组合,可以支持按需引入的效果。Snabbdom 模块系统的特点可以概括为:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。当然 Snabbdom 模块系统还有其他内置模块:模块名称模块功能示例代码attributesModule为 DOM 元素设置属性,在属性添加和更新时使用 setAttribute 方法。h('a', { attrs: { href: '/foo' } }, 'Go to Foo')classModule用来动态设置和切换 DOM 元素上的 class 名称。h('a', { class: { active: true, selected: false } }, 'Toggle')datasetModule为 DOM 元素设置自定义数据属性(data- *)。然后可以使用 HTMLElement.dataset 属性访问它们。h('button', { dataset: { action: 'reset' } }, 'Reset')eventListenersModule为 DOM 元素绑定事件监听器。h('div', { on: { click: clickHandler } })propsModule为 DOM 元素设置属性,如果同时使用 attributesModule,则会被 attributesModule 覆盖。h('a', { props: { href: '/foo' } }, 'Go to Foo')styleModule为 DOM 元素设置 CSS 属性。h('span', {style: { color: '#c0ffee'}}, 'Say my name')2. Hooks 介绍Hooks 也称钩子,是 DOM 节点生命周期的一种方法。Snabbdom 提供丰富的钩子选择。模块既使用钩子来扩展 Snabbdom,也在普通代码中使用钩子,用来在 DOM 节点生命周期中执行任意代码。这里大致介绍一下所有的 Hooks:钩子名称触发时机回调参数prepatch 阶段开始。noneinit已添加一个 VNode。vnodecreate基于 VNode 创建了一个 DOM 元素。emptyVnode, vnodeinsert一个元素已添加到 DOM 元素中。vnodeprepatch一个元素即将进入 patch 阶段。oldVnode, vnodeupdate一个元素开始更新。oldVnode, vnodepostpatch一个元素完成 patch 阶段。oldVnode, vnodedestroy一个元素直接或间接被删除。vnoderemove一个元素直接从 DOM 元素中删除。vnode, removeCallbackpostpatch 阶段结束。none模块中可以使用这些钩子:pre, create, update, destroy, remove, post。 单个元素可以使用这些钩子:init, create, insert, prepatch, update, postpatch, destroy, remove。Snabbdom 是这么定义钩子的:// snabbdom/src/package/hooks.ts export type PreHook = () => any export type InitHook = (vNode: VNode) => any export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any export type InsertHook = (vNode: VNode) => any export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any export type DestroyHook = (vNode: VNode) => any export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any export type PostHook = () => any export interface Hooks { pre?: PreHook init?: InitHook create?: CreateHook insert?: InsertHook prepatch?: PrePatchHook update?: UpdateHook postpatch?: PostPatchHook destroy?: DestroyHook remove?: RemoveHook post?: PostHook }接下来我们通过 03-modules.js 文件的示例代码,我们需要样式处理和事件操作,因此引入这两个模块,并进行灵活组合:// src/03-modules.js import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' // 1. 导入模块 import { styleModule } from 'snabbdom/src/package/modules/style' import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners' // 2. 注册模块 const patch = init([ styleModule, eventListenersModule ]) // 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象) let vnode = h('div', { style: { backgroundColor: '#4fc08d', color: '#35495d' }, on: { click: eventHandler } }, [ h('h1', 'Hello Snabbdom'), h('p', 'This is p tag') ]) function eventHandler() { console.log('clicked.') } const app = document.getElementById('app') patch(app, vnode)上面代码中,引入了 styleModule 和 eventListenersModule 两个模块,并且作为参数组合,传入 init() 函数中。 此时我们可以看到页面上显示的内容已经有包含样式,并且点击事件也能正常输出日志 'clicked.' :这里我们看下 styleModule 模块源码,把代码精简一下:// snabbdom/src/package/modules/style.ts function updateStyle (oldVnode: VNode, vnode: VNode): void { // 省略其他代码 } function forceReflow () { // 省略其他代码 } function applyDestroyStyle (vnode: VNode): void { // 省略其他代码 } function applyRemoveStyle (vnode: VNode, rm: () => void): void { // 省略其他代码 } export const styleModule: Module = { pre: forceReflow, create: updateStyle, update: updateStyle, destroy: applyDestroyStyle, remove: applyRemoveStyle }在看看 eventListenersModule 模块源码:// snabbdom/src/package/modules/eventlisteners.ts function updateEventListeners (oldVnode: VNode, vnode?: VNode): void { // 省略其他代码 } export const eventListenersModule: Module = { create: updateEventListeners, update: updateEventListeners, destroy: updateEventListeners }明显可以看出,两个模块返回的都是个对象,并且每个属性为一种钩子,如 pre/create 等,值为对应的处理函数,每个处理函数有统一的入参。继续看下 styleModule 中,样式是如何绑定上去的。这里分析它的 updateStyle 方法,因为元素创建(create 钩子)和元素更新(update 钩子)阶段都是通过这个方法处理:// snabbdom/src/package/modules/style.ts function updateStyle (oldVnode: VNode, vnode: VNode): void { var cur: any var name: string var elm = vnode.elm var oldStyle = (oldVnode.data as VNodeData).style var style = (vnode.data as VNodeData).style if (!oldStyle && !style) return if (oldStyle === style) return // 1. 设置新旧 style 默认值 oldStyle = oldStyle || {} style = style || {} var oldHasDel = 'delayed' in oldStyle // 2. 比较新旧 style for (name in oldStyle) { if (!style[name]) { if (name[0] === '-' && name[1] === '-') { (elm as any).style.removeProperty(name) } else { (elm as any).style[name] = '' } } } for (name in style) { cur = style[name] if (name === 'delayed' && style.delayed) { // 省略部分代码 } else if (name !== 'remove' && cur !== oldStyle[name]) { if (name[0] === '-' && name[1] === '-') { (elm as any).style.setProperty(name, cur) } else { // 3. 设置新 style 到元素 (elm as any).style[name] = cur } } } }3. init() 分析接着我们看下 init() 函数内部如何处理这些 Module。首先在 init.ts 文件中,可以看到声明了默认支持的 Hooks 钩子列表:// snabbdom/src/package/init.ts const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']接着看 hooks 是如何使用的:// snabbdom/src/package/init.ts export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { let i: number let j: number const cbs: ModuleHooks = { // 创建 cbs 对象,用于收集 module 中的 hook create: [], update: [], remove: [], destroy: [], pre: [], post: [] } // 收集 module 中的 hook,并保存在 cbs 中 for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { const hook = modules[j][hooks[i]] if (hook !== undefined) { (cbs[hooks[i]] as any[]).push(hook) } } } // 省略其他代码,稍后介绍 }上面代码中,创建 hooks 变量用来声明默认支持的 Hooks 钩子,在 init() 函数中,创建 cbs 对象,通过两层循环,保存每个 module 中的 hook 函数到 cbs 对象的指定钩子中。通过断点可以看到这是 demo 中,cbs 对象是下面这个样子:这里 cbs 对象收集了每个 module 中的 Hooks 处理函数,保存到对应 Hooks 数组中。比如这里的 create 钩子中保存了 updateStyle 函数和 updateEventListeners 函数。到这里, init() 函数已经保存好所有 module 的 Hooks 处理函数,接下来就要看看 init() 函数返回的 patch() 函数,这里面将用到前面保存好的 cbs 对象。4. patch() 分析init() 函数中最终返回一个 patch() 函数,这边形成一个闭包,闭包里面可以使用到 init() 函数作用域定义的变量和方法,因此在 patch() 函数中能使用 cbs 对象。patch() 函数会在不同时机点(可以参照前面的 Hooks 介绍),遍历 cbs 对象中不同 Hooks 处理函数列表。// snabbdom/src/package/init.ts export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { // 省略其他代码 return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node const insertedVnodeQueue: VNodeQueue = [] for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() // [Hooks]遍历 pre Hooks 处理函数列表 if (!isVnode(oldVnode)) { oldVnode = emptyNodeAt(oldVnode) // 当 oldVnode 参数不是 VNode 则创建一个空的 VNode } if (sameVnode(oldVnode, vnode)) { // 当两个 VNode 为同一个 VNode,则进行比较和更新 patchVnode(oldVnode, vnode, insertedVnodeQueue) } else { createElm(vnode, insertedVnodeQueue) // 当两个 VNode 不同,则创建新元素 if (parent !== null) { // 当该 oldVnode 有父节点,则插入该节点,然后移除原来节点 api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) removeVnodes(parent, [oldVnode], 0, 0) } } for (i = 0; i < cbs.post.length; ++i) cbs.post[i]() // [Hooks]遍历 post Hooks 处理函数列表 return vnode } }patchVnode() 函数定义如下: function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { // 省略其他代码 if (vnode.data !== undefined) { for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) // [Hooks]遍历 update Hooks 处理函数列表 } }createVnode() 函数定义如下: function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { // 省略其他代码 const sel = vnode.sel if (sel === '!') { // 省略其他代码 } else if (sel !== undefined) { for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // [Hooks]遍历 create Hooks 处理函数列表 const hook = vnode.data!.hook } return vnode.elm }removeNodes() 函数定义如下: function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void { // 省略其他代码 for (; startIdx <= endIdx; ++startIdx) { const ch = vnodes[startIdx] if (ch != null) { rm = createRmCb(ch.elm!, listeners) for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // [Hooks]遍历 remove Hooks 处理函数列表 } } }这部分代码跳转较多,总结一下这个过程,如下图:四、自定义 Snabbdom 模块前面我们介绍了 Snabbdom 模块系统是如何收集 Hooks 并保存下来,然后在不同时机点执行不同的 Hooks。在 Snabbdom 中,所有模块独立在 src/package/modules 下,使用的时候可以灵活组合,也方便做解耦和跨平台,并且所有 Module 返回的对象中每个 Hooks 类型如下:// snabbdom/src/package/init.ts export type Module = Partial<{ pre: PreHook create: CreateHook update: UpdateHook destroy: DestroyHook remove: RemoveHook post: PostHook }> // snabbdom/src/package/hooks.ts export type PreHook = () => any export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any export type DestroyHook = (vNode: VNode) => any export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any export type PostHook = () => any因此,如果开发者需要自定义模块,只需实现不同 Hooks 并导出即可。接下来我们实现一个简单的模块 replaceTagModule,用来将节点文本自动过滤掉 HTML 标签。1. 初始化代码考虑到方便调试,我们直接在 node_modules/snabbdom/src/package/modules/ 目录中新建 replaceTag.ts 文件,然后写个最简单的 demo 框架:import { VNode, VNodeData } from '../vnode' import { Module } from './module' const replaceTagPre = () => { console.log("run replaceTagPre!") } const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => { console.log("run updateReplaceTag!", oldVnode, vnode) } const removeReplaceTag = (vnode: VNode): void => { console.log("run removeReplaceTag!", vnode) } export const replaceTagModule: Module = { pre: replaceTagPre, create: updateReplaceTag, update: updateReplaceTag, remove: removeReplaceTag }接下来引入到 03-modules.js 代码中,并简化下代码:import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' // 1. 导入模块 import { styleModule } from 'snabbdom/src/package/modules/style' import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners' import { replaceTagModule } from 'snabbdom/src/package/modules/replaceTag'; // 2. 注册模块 const patch = init([ styleModule, eventListenersModule, replaceTagModule ]) // 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象) let vnode = h('div', '<h1>Hello Leo</h1>') const app = document.getElementById('app') const oldVNode = patch(app, vnode) let newVNode = h('div', '<div>Hello Leo</div>') patch(oldVNode, newVNode)刷新浏览器,就可以看到 replaceTagModule 的每个钩子都被正常执行:2. 实现 updateReplaceTag() 函数我们删除掉多余代码,接下来实现 updateReplaceTag() 函数,当 vnode 创建和更新时,都会调用该方法。import { VNode, VNodeData } from '../vnode' import { Module } from './module' const regFunction = str => str && str.replace(/\<|\>|\//g, ""); const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => { const oldVnodeReplace = regFunction(oldVnode.text); const vnodeReplace = regFunction(vnode.text); if(oldVnodeReplace === vnodeReplace) return; vnode.text = vnodeReplace; } export const replaceTagModule: Module = { create: updateReplaceTag, update: updateReplaceTag, } 在 updateReplaceTag() 函数中,比较新旧 vnode 的文本内容是否一致,如果一致则直接返回,否则将新的 vnode 的替换后的文本设置到 vnode 的 text 属性,完成更新。其中有个细节:vnode.text = vnodeReplace;这里直接对 vnode.text 进行赋值,页面上的内容也随之发生变化。这是因为 vnode 是个响应式对象,通过调用其 setter 方法,会触发响应式更新,这样就实现页面内容更新。于是我们看到页面内容中的 HTML 标签被清空了。3. 小结这个小节中,我们实现一个简单的 replaceTagModule 模块,体验了一下 Snabbdom 模块灵活组合的特点,当我们需要自定义某些模块时,便可以按照 Snabbdom 的模块开发方式,开发自定义模块,然后通过 Snabbdom 的 init() 函数注入模块即可。我们再回顾一下 Snabbdom 模块系统特点:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。五、通用模块生命周期模型下面我将前面 Snabbdom 的模块系统,抽象为一个通用模块生命周期模型,其中包含三个核心层:模块定义层在本层可以按照模块开发规范,自定义各种模块。模块应用层一般是在业务开发层或组件层中,用来导入模块。模块初始化层一般是在开发的模块系统的插件中,提供初始化函数(init 函数),执行初始化函数会遍历每个 Hooks,并执行对应处理函数列表的每个函数。抽象后的模型如下:在使用 Module 的时候就可以灵活组合搭配使用啦,在模块初始化层,就会做好调用。六、总结本文主要以 Snabbdom-demo 仓库为学习示例,学习了 Snabbdom 运行流程和 Snabbdom 模块系统的运行流程,还通过手写一个简单的 Snabbdom 模块,带大家领略一下 Snabbdom 模块的魅力,最后为大家总结了一个通用模块插件模型。大家好好掌握 Snabbdom 对理解 Vue 会很有帮助。
近几年随着 React、Vue 等前端框架不断兴起,Virtual DOM 概念也越来越火,被用到越来越多的框架、库中。Virtual DOM 是基于真实 DOM 的一层抽象,用简单的 JS 对象描述真实 DOM。本文要介绍的 Snabbdom 就是 Virtual DOM 的一种简单实现,并且 Vue 的 Virtual DOM 也参考了 Snabbdom 实现方式。对于想要深入学习 Vue Virtual DOM 的朋友,建议先学习 Snabbdom,对理解 Vue 会很有帮助,并且其核心代码 200 多行。本文挑选 Snabbdom 模块系统作为主要核心点介绍,其他内容可以查阅官方文档《Snabbdom》。一、Snabbdom 是什么Snabbdom 是一个专注于简单性、模块化、强大特性和性能的虚拟 DOM 库。其中有几个核心特性:核心代码 200 行,并且提供丰富的测试用例;拥有强大模块系统,并且支持模块拓展和灵活组合;在每个 VNode 和全局模块上,都有丰富的钩子,可以在 Diff 和 Patch 阶段使用。接下来从一个简单示例来体验一下 Snabbdom。1. 快速上手安装 Snabbdom:npm install snabbdom -D接着新建 index.html,设置入口元素:<div id="app"></div>然后新建 demo1.js 文件,并使用 Snabbdom 提供的函数:// demo1.js import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' const patch = init([]) let vnode = h('div#app', 'Hello Leo') const app = document.getElementById('app') patch(app, vnode)这样就实现一个简单示例,在浏览器打开 index.html,页面将显示 “Hello Leo” 文本。 接下来,我会以 snabbdom-demo 项目作为学习示例,从简单示例到模块系统使用的示例,深入学习和分析 Snabbdom 源码,重点分析 Snabbdom 模块系统。二、Snabbdom-demo 分析Snabbdom-demo 项目中的三个演示代码,为我们展示如何从简单到深入 Snabbdom。 首先克隆仓库并安装:$ git clone https://github.com/zyycode/snabbdom-demo.git $ npm install虽然本项目没有 README.md 文件,但项目目录比较直观,我们可以轻松的从 src 目录找到这三个示例代码的文件:01-basicusage.js02-basicusage.js03-modules.js -> 本文核心介绍接着在 index.html 中引入想要学习的代码文件,默认 <script src="./src/01-basicusage.js"></script> ,通过 package.json 可知启动命令并启动项目:$ npm run dev1. 简单示例分析当我们要研究一个库或框架等比较复杂的项目,可以通过官方提供的简单示例代码进行分析,我们这里选择该项目中最简单的 01-basicusage.js 代码进行分析,其代码如下:// src/01-basicusage.js import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' const patch = init([]) let vnode = h('div#container.cls', 'Hello World') const app = document.getElementById('app') // 入口元素 const oldVNode = patch(app, vnode) // 假设时刻 vnode = h('div', 'Hello Snabbdom') patch(oldVNode, vnode)运行项目以后,可以看到页面展示了“Hello Snabbdom”文本,这里你会觉得奇怪,前面的 “Hello World” 文本去哪了?原因很简单,我们把 demo 中的下面两行代码注释后,页面便显示文本是 “Hello World”:vnode = h('div', 'Hello Snabbdom') patch(oldVNode, vnode)这里我们可以猜测 patch() 函数可以将** VNode** 渲染到页面。更进一步可以理解为,这边第一个执行 patch() 函数为首次渲染,第二次执行 patch() 函数为更新操作。2. VNode 介绍这里可能会有小伙伴疑惑,示例中的 VNode 是什么?这里简单解释下:VNode,该对象用于描述节点的信息,它的全称是虚拟节点(virtual node)。与 “虚拟节点” 相关联的另一个概念是 “虚拟 DOM”,它是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。“虚拟 DOM” 由 VNode 组成的。 —— 全栈修仙之路 《Vue 3.0 进阶之 VNode 探秘》其实 VNode 就是一个 JS 对象,在 Snabbdom 中是这么定义 VNode 的类型:export interface VNode { sel: string | undefined; // selector的缩写 data: VNodeData | undefined; // 下面VNodeData接口的内容 children: Array<VNode | string> | undefined; // 子节点 elm: Node | undefined; // element的缩写,存储了真实的HTMLElement text: string | undefined; // 如果是文本节点,则存储text key: Key | undefined; // 节点的key,在做列表时很有用 } export interface VNodeData { props?: Props attrs?: Attrs class?: Classes style?: VNodeStyle dataset?: Dataset on?: On hero?: Hero attachData?: AttachData hook?: Hooks key?: Key ns?: string // for SVGs fn?: () => VNode // for thunks args?: any[] // for thunks [key: string]: any // for any other 3rd party module }在 VNode 对象中含描述节点选择器 sel 字段、节点数据 data 字段、节点所包含的子节点 children 字段等。在这个 demo 中,我们似乎并没有看到模块系统相关的代码,没事,因为这是最简单的示例,下一节会详细介绍。我们在学习一个函数时,可以重点了解该函数的“入参”和“出参”,大致就能判断该函数的作用。从这个 demo 主要执行过程可以看出,主要用到有三个函数: init() / patch() / h() ,它们到底做什么用的呢?我们分析一下 Snabbdom 源码中这三个函数的入参和出参情况:3. init() 函数分析init() 函数被定义在 package/init.ts 文件中:// node_modules/snabbdom/src/package/init.ts export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { // 省略其他代码 }其参数类型如下:function init(modules: Array<Partial<Module>>, domApi?: DOMAPI): (oldVnode: VNode | Element, vnode: VNode) => VNode export type Module = Partial<{ pre: PreHook create: CreateHook update: UpdateHook destroy: DestroyHook remove: RemoveHook post: PostHook }> export interface DOMAPI { createElement: (tagName: any) => HTMLElement createElementNS: (namespaceURI: string, qualifiedName: string) => Element createTextNode: (text: string) => Text createComment: (text: string) => Comment insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void removeChild: (node: Node, child: Node) => void appendChild: (node: Node, child: Node) => void parentNode: (node: Node) => Node | null nextSibling: (node: Node) => Node | null tagName: (elm: Element) => string setTextContent: (node: Node, text: string | null) => void getTextContent: (node: Node) => string | null isElement: (node: Node) => node is Element isText: (node: Node) => node is Text isComment: (node: Node) => node is Comment }init() 函数接收一个模块数组 modules 和可选的 domApi 对象作为参数,返回一个函数,即 patch() 函数。 domApi 对象的接口包含了很多 DOM 操作的方法。 这里的 modules 参数本文将重点介绍。4. patch() 函数分析init() 函数返回了一个 patch() 函数,其类型为:// node_modules/snabbdom/src/package/init.ts patch(oldVnode: VNode | Element, vnode: VNode) => VNodepatch() 函数接收两个 VNode 对象作为参数,并返回一个新 VNode。5. h() 函数分析h() 函数被定义在 package/h.ts 文件中:// node_modules/snabbdom/src/package/h.ts export function h(sel: string): VNode export function h(sel: string, data: VNodeData | null): VNode export function h(sel: string, children: VNodeChildren): VNode export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode export function h (sel: any, b?: any, c?: any): VNode{ // 省略其他代码 }h() 函数接收多种参数,其中必须有一个 sel 参数,作用是将节点内容挂载到该容器中,并返回一个新 VNode。6. 小结通过前面介绍,我们在回过头看看这个 demo 的代码,大致调用流程如下:
五、Vue.js 响应式实现本节代码:github.com/pingan8787/…这里大家可以再回顾下下面这张官网经典的图,思考下前面讲的示例。 (图片来自:cn.vuejs.org/v2/guide/re…)上一节实现了简单的数据响应式,接下来继续通过完善该示例,实现一个简单的 Vue.js 响应式,测试代码如下:// index.js const vm = new Vue({ el: '#app', data(){ return { text: '你好,前端自习课', desc: '每日清晨,享受一篇前端优秀文章。' } } });是不是很有内味了,下面是我们最终实现后项目目录:- mini-reactive / index.html // 入口 HTML 文件 / index.js // 入口 JS 文件 / observer.js // 实现响应式,将数据转换为响应式对象 / watcher.js // 实现观察者和被观察者(依赖收集者) / vue.js // 实现 Vue 类作为主入口类 / compile.js // 实现编译模版功能知道每一个文件功能以后,接下来将每一步串联起来。1. 实现入口文件我们首先实现入口文件,包括 index.html / index.js 2 个简单文件,用来方便接下来的测试。1.1 index.html<!DOCTYPE html> <html lang="en"> <head> <script src="./vue.js"></script> <script src="./observer.js"></script> <script src="./compile.js"></script> <script src="./watcher.js"></script> </head> <body> <div id="app">{{text}}</div> <button id="update">更新数据</button> <script src="./index.js"></script> </body> </html>1.2 index.js"use strict"; const vm = new Vue({ el: '#app', data(){ return { text: '你好,前端自习课', desc: '每日清晨,享受一篇前端优秀文章。' } } }); console.log(vm.$data.text) vm.$data.text = '页面数据更新成功!'; // 模拟数据变化 console.log(vm.$data.text)2. 实现核心入口 vue.jsvue.js 文件是我们实现的整个响应式的入口文件,暴露一个 Vue 类,并挂载全局。class Vue { constructor (options = {}) { this.$el = options.el; this.$data = options.data(); this.$methods = options.methods; // [核心流程]将普通 data 对象转换为响应式对象 new Observer(this.$data); if (this.$el) { // [核心流程]将解析模板的内容 new Compile(this.$el, this) } } } window.Vue = Vue;Vue 类入参为一个配置项 option ,使用起来跟 Vue.js 一样,包括 $el 挂载点、 $data 数据对象和 $methods 方法列表(本文不详细介绍)。通过实例化 Oberser 类,将普通 data 对象转换为响应式对象,然后判断是否传入 el 参数,存在时,则实例化 Compile 类,解析模版内容。总结下 Vue 这个类工作流程 : 3. 实现 observer.jsobserver.js 文件实现了 Observer 类,用来将普通对象转换为响应式对象:class Observer { constructor (data) { this.data = data; this.walk(data); } // [核心方法]将 data 对象转换为响应式对象,为每个 data 属性设置 getter 和 setter 方法 walk (data) { if (typeof data !== 'object') return data; Object.keys(data).forEach( key => { this.defineReactive(data, key, data[key]) }) } // [核心方法]实现数据劫持 defineReactive (obj, key, value) { this.walk(value); // [核心过程]遍历 walk 方法,处理深层对象。 const dep = new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get () { console.log('[getter]方法执行') Dep.target && dep.addSub(Dep.target); return value }, set (newValue) { console.log('[setter]方法执行') if (value === newValue) return; // [核心过程]当设置的新值 newValue 为对象,则继续通过 walk 方法将其转换为响应式对象 if (typeof newValue === 'object') this.walk(newValue); value = newValue; dep.notify(); // [核心过程]执行被观察者通知方法,通知所有观察者执行 update 更新 } }) } }相比较第四节实现的 Observer 类,这里做了调整:增加 walk 核心方法,用来遍历对象每个属性,分别调用数据劫持方法( defineReactive() );在 defineReactive() 的 getter 中,判断 Dep.target 存在才添加观察者,下一节会详细介绍 Dep.target;在 defineReactive() 的 setter 中,判断当前新值( newValue )是否为对象,如果是,则直接调用 this.walk() 方法将当前对象再次转为响应式对象,处理深层对象。通过改善后的 Observer 类,我们就可以实现将单层或深层嵌套的普通对象转换为响应式对象。4. 实现 watcher.js这里实现了 Dep 被观察者类(依赖收集者)和 Watcher 观察者类。class Dep { constructor() { this.subs = []; } addSub(watcher) { this.subs.push(watcher); } notify(data) { this.subs.forEach(sub => sub.update(data)); } } class Watcher { constructor (vm, key, cb) { this.vm = vm; // vm:表示当前实例 this.key = key; // key:表示当前操作的数据名称 this.cb = cb; // cb:表示数据发生改变之后的回调 Dep.target = this; // 全局唯一 // 此处通过 this.vm.$data[key] 读取属性值,触发 getter this.oldValue = this.vm.$data[key]; // 保存变化的数据作为旧值,后续作判断是否更新 // 前面 getter 执行完后,执行下面清空 Dep.target = null; } update () { console.log(`数据发生变化!`); let oldValue = this.oldValue; let newValue = this.vm.$data[this.key]; if (oldValue != newValue) { // 比较新旧值,发生变化才执行回调 this.cb(newValue, oldValue); }; } }相比较第四节实现的 Watcher 类,这里做了调整:在构造函数中,增加 Dep.target 值操作;在构造函数中,增加 oldValue 变量,保存变化的数据作为旧值,后续作为判断是否更新的依据;在 update() 方法中,增加当前操作对象 key 对应值的新旧值比较,如果不同,才执行回调。Dep.target 是当前全局唯一的订阅者,因为同一时间只允许一个订阅者被处理。target 指当前正在处理的目标订阅者,当前订阅者处理完就赋值为 null 。这里 Dep.target 会在 defineReactive() 的 getter 中使用到。通过改善后的 Watcher 类,我们操作当前操作对象 key 对应值的时候,可以在数据有变化的情况才执行回调,减少资源浪费。4. 实现 compile.jscompile.js 实现了 Vue.js 的模版编译,如将 HTML 中的 {{text}} 模版转换为具体变量的值。compile.js 介绍内容较多,考虑到篇幅问题,并且本文核心介绍响应式原理,所以这里就暂时不介绍 compile.js 的实现,在学习的朋友可以到我 Github 上下载该文件直接下载使用即可,地址: github.com/pingan8787/…5. 测试代码到这里,我们已经将第四节的 demo 改造成简易版 Vue.js 响应式,接下来打开 index.html 看看效果: 当 index.js 中执行到:vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';页面便发生更新,页面显示的文本内容从“你好,前端自习课”更新成“我们必须经常保持旧的记忆和新的希望。”。到这里,我们的简易版 Vue.js 响应式原理实现好了,能跟着文章看到这里的朋友,给你点个大大的赞👍 六、总结本文首先通过回顾观察者模式和 Object.defineProperty() 方法,介绍 Vue.js 响应式原理的核心知识点,然后带大家通过一个简单示例实现简单响应式,最后通过改造这个简单响应式的示例,实现一个简单 Vue.js 响应式原理的示例。相信看完本文的朋友,对 Vue.js 的响应式原理的理解会更深刻,希望大家理清思路,再好好回味下~参考资料官方文档 - 深入响应式原理《浅谈Vue响应式原理》《Vue的数据响应式原理》
原创文章推荐😊:《1.2w字 | 初中级前端 JavaScript 自测清单 - 1》《1.2w字 | 初中级前端 JavaScript 自测清单 - 2》《图解设计模式之观察者模式(TypeScript)》《图解设计模式之发布-订阅模式(TypeScript)》React 是一个 Facebook 开源的,用于构建用户界面的 JavaScript 库。React 目的在于解决:构建随着时间数据不断变化的大规模应用程序。 其中 React 合成事件是较为重要的知识点,阅读完本文,你将收获:合成事件的概念和作用;合成事件与原生事件的 3 个区别;合成事件与原生事件的执行顺序;合成事件的事件池;合成事件 4 个常见问题。接下来和我一起开始学习吧~一、概念介绍React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器。它根据 W3C 规范 来定义合成事件,兼容所有浏览器,拥有与浏览器原生事件相同的接口。 看个简单示例:const button = <button onClick={handleClick}>Leo 按钮</button>在 React 中,所有事件都是合成的,不是原生 DOM 事件,但可以通过 e.nativeEvent 属性获取 DOM 事件。const handleClick = (e) => console.log(e.nativeEvent);; const button = <button onClick={handleClick}>Leo 按钮</button>学习一个新知识的时候,一定要知道为什么会出现这个技术。 那么 React 为什么使用合成事件?其主要有三个目的:进行浏览器兼容,实现更好的跨平台React 采用的是顶层事件代理机制,能够保证冒泡一致性,可以跨浏览器执行。React 提供的合成事件用来抹平不同浏览器事件对象之间的差异,将不同平台事件模拟合成事件。避免垃圾回收事件对象可能会被频繁创建和回收,因此 React 引入事件池,在事件池中获取或释放事件对象。即 React 事件对象不会被释放掉,而是存放进一个数组中,当事件触发,就从这个数组中弹出,避免频繁地去创建和销毁(垃圾回收)。方便事件统一管理和事务机制本文不介绍源码啦,对具体实现的源码有兴趣的朋友可以查阅:《React SyntheticEvent》 。二、原生事件回顾在开始介绍 React 合成事件之前,我们先简单回顾 JavaScript 原生事件中几个重要知识点: 1. 事件捕获当某个元素触发某个事件(如 onclick ),顶层对象 document 就会发出一个事件流,随着 DOM 树的节点向目标元素节点流去,直到到达事件真正发生的目标元素。在这个过程中,事件相应的监听函数是不会被触发的。2. 事件目标当到达目标元素之后,执行目标元素该事件相应的处理函数。如果没有绑定监听函数,那就不执行。3. 事件冒泡从目标元素开始,往顶层元素传播。途中如果有节点绑定了相应的事件处理函数,这些函数都会被触发一次。如果想阻止事件起泡,可以使用 e.stopPropagation() 或者 e.cancelBubble=true(IE)来阻止事件的冒泡传播。4. 事件委托/事件代理简单理解就是将一个响应事件委托到另一个元素。 当子节点被点击时,click 事件向上冒泡,父节点捕获到事件后,我们判断是否为所需的节点,然后进行处理。其优点在于减少内存消耗和动态绑定事件。二、合成事件与原生事件区别React 事件与原生事件很相似,但不完全相同。这里列举几个常见区别:1. 事件名称命名方式不同原生事件命名为纯小写(onclick, onblur),而 React 事件命名采用小驼峰式(camelCase),如 onClick 等:// 原生事件绑定方式 <button onclick="handleClick()">Leo 按钮命名</button> // React 合成事件绑定方式 const button = <button onClick={handleClick}>Leo 按钮命名</button>2. 事件处理函数写法不同原生事件中事件处理函数为字符串,在 React JSX 语法中,传入一个函数作为事件处理函数。// 原生事件 事件处理函数写法 <button onclick="handleClick()">Leo 按钮命名</button> // React 合成事件 事件处理函数写法 const button = <button onClick={handleClick}>Leo 按钮命名</button>3. 阻止默认行为方式不同在原生事件中,可以通过返回 false 方式来阻止默认行为,但是在 React 中,需要显式使用 preventDefault() 方法来阻止。 这里以阻止 <a> 标签默认打开新页面为例,介绍两种事件区别:// 原生事件阻止默认行为方式 <a href="https://www.pingan8787.com" onclick="console.log('Leo 阻止原生事件~'); return false" > Leo 阻止原生事件 </a> // React 事件阻止默认行为方式 const handleClick = e => { e.preventDefault(); console.log('Leo 阻止原生事件~'); } const clickElement = <a href="https://www.pingan8787.com" onClick={handleClick}> Leo 阻止原生事件 </a>4. 小结小结前面几点区别:原生事件React 事件事件名称命名方式名称全部小写(onclick, onblur)名称采用小驼峰(onClick, onBlur)事件处理函数语法字符串函数阻止默认行为方式事件返回 false使用 e.preventDefault() 方法三、React 事件与原生事件执行顺序在 React 中,“合成事件”会以事件委托(Event Delegation)方式绑定在组件最上层,并在组件卸载(unmount)阶段自动销毁绑定的事件。这里我们手写一个简单示例来观察 React 事件和原生事件的执行顺序:class App extends React.Component<any, any> { parentRef: any; childRef: any; constructor(props: any) { super(props); this.parentRef = React.createRef(); this.childRef = React.createRef(); } componentDidMount() { console.log("React componentDidMount!"); this.parentRef.current?.addEventListener("click", () => { console.log("原生事件:父元素 DOM 事件监听!"); }); this.childRef.current?.addEventListener("click", () => { console.log("原生事件:子元素 DOM 事件监听!"); }); document.addEventListener("click", (e) => { console.log("原生事件:document DOM 事件监听!"); }); } parentClickFun = () => { console.log("React 事件:父元素事件监听!"); }; childClickFun = () => { console.log("React 事件:子元素事件监听!"); }; render() { return ( <div ref={this.parentRef} onClick={this.parentClickFun}> <div ref={this.childRef} onClick={this.childClickFun}> 分析事件执行顺序 </div> </div> ); } } export default App;触发事件后,可以看到控制台输出:原生事件:子元素 DOM 事件监听! 原生事件:父元素 DOM 事件监听! React 事件:子元素事件监听! React 事件:父元素事件监听! 原生事件:document DOM 事件监听! 通过上面流程,我们可以理解:React 所有事件都挂载在 document 对象上;当真实 DOM 元素触发事件,会冒泡到 document 对象后,再处理 React 事件;所以会先执行原生事件,然后处理 React 事件;最后真正执行 document 上挂载的事件。四、合成事件的事件池1. 事件池介绍合成事件对象池,是 React 事件系统提供的一种性能优化方式。合成事件对象在事件池统一管理,不同类型的合成事件具有不同的事件池。当事件池未满时,React 创建新的事件对象,派发给组件。当事件池装满时,React 从事件池中复用事件对象,派发给组件。关于“事件池是如何工作”的问题,可以看看下面图片:(图片来自:ReactDeveloper juejin.cn/post/684490…)2. 事件池分析(React 16 版本)React 事件池仅支持在 React 16 及更早版本中,在 React 17 已经不使用事件池。 下面以 React 16 版本为例:function handleChange(e) { console.log("原始数据:", e.target) setTimeout(() => { console.log("定时任务 e.target:", e.target); // null console.log("定时任务:e:", e); }, 100); } function App() { return ( <div className="App"> <button onClick={handleChange}>测试事件池</button> </div> ); } export default App; 可以看到输出:在 React 16 及之前的版本,合成事件对象的事件处理函数全部被调用之后,所有属性都会被置为 null 。这时,如果我们需要在事件处理函数运行之后获取事件对象的属性,可以使用 React 提供的 e.persist() 方法,保留所有属性:// 只修改 handleChange 方法,其他不变 function handleChange(e) { // 只增加 persist() 执行 e.persist(); console.log("原始数据:", e.target) setTimeout(() => { console.log("定时任务 e.target:", e.target); // null console.log("定时任务:e:", e); }, 100); }再看下结果:3. 事件池分析(React 17 版本)由于 Web 端的 React 17 不使用事件池,所有不会存在上述“所有属性都会被置为 null”的问题。五、常见问题1. React 事件中 this 指向问题在 React 中,JSX 回调函数中的 this 经常会出问题,在 Class 中方法不会默认绑定 this,就会出现下面情况, this.funName 值为 undefined :class App extends React.Component<any, any> { childClickFun = () => { console.log("React 事件"); }; clickFun() { console.log("React this 指向问题", this.childClickFun); // undefined } render() { return ( <div onClick={this.clickFun}>React this 指向问题</div> ); } } export default App;我们有 2 种方式解决这个问题:使用 bind 方法绑定 this :class App extends React.Component<any, any> { constructor(props: any) { super(props); this.clickFun = this.clickFun.bind(this); } // 省略其他代码 } export default App;将需要使用 this 的方法改写为使用箭头函数定义:class App extends React.Component<any, any> { clickFun = () => { console.log("React this 指向问题", this.childClickFun); // undefined } // 省略其他代码 } export default App;或者在回调函数中使用箭头函数:class App extends React.Component<any, any> { // 省略其他代码 clickFun() { console.log("React this 指向问题", this.childClickFun); // undefined } render() { return ( <div onClick={() => this.clickFun()}>React this 指向问题</div> ); } } export default App;2. 向事件传递参数问题经常在遍历列表时,需要向事件传递额外参数,如 id 等,来指定需要操作的数据,在 React 中,可以使用 2 种方式向事件传参:const List = [1,2,3,4]; class App extends React.Component<any, any> { // 省略其他代码 clickFun (id) {console.log('当前点击:', id)} render() { return ( <div> <h1>第一种:通过 bind 绑定 this 传参</h1> { List.map(item => <div onClick={this.clickFun.bind(this, item)}>按钮:{item}</div>) } <h1>第二种:通过箭头函数绑定 this 传参</h1> { List.map(item => <div onClick={() => this.clickFun(item)}>按钮:{item}</div>) } </div> ); } } export default App;这两种方式是等价的:第一种通过 Function.prototype.bind 实现;第二种通过箭头函数实现。3. 合成事件阻止冒泡官网文档描述了:从 v0.14 开始,事件处理器返回 false 时,不再阻止事件传递。你可以酌情手动调用 e.stopPropagation() 或 e.preventDefault() 作为替代方案。也就是说,在 React 合成事件中,需要阻止冒泡时,可以使用 e.stopPropagation() 或 e.preventDefault() 方法来解决,另外还可以使用 e.nativeEvent.stopImmediatePropagation() 方法解决。3.1 e.stopPropagation对于开发者来说,更希望使用 e.stopPropagation() 方法来阻止当前 DOM 事件冒泡,但事实上,从前两节介绍的执行顺序可知,e.stopPropagation() 只能阻止合成事件间冒泡,即下层的合成事件,不会冒泡到上层的合成事件。事件本身还都是在 document 上执行。所以最多只能阻止 document 事件不能再冒泡到 window 上。class App extends React.Component<any, any> { parentRef: any; childRef: any; constructor(props: any) { super(props); this.parentRef = React.createRef(); } componentDidMount() { this.parentRef.current?.addEventListener("click", () => { console.log("阻止原生事件冒泡~"); }); document.addEventListener("click", (e) => { console.log("原生事件:document DOM 事件监听!"); }); } parentClickFun = (e: any) => { e.stopPropagation(); console.log("阻止合成事件冒泡~"); }; render() { return ( <div ref={this.parentRef} onClick={this.parentClickFun}> 点击测试“合成事件和原生事件是否可以混用” </div> ); } } export default App;输出结果:阻止原生事件冒泡~ 阻止合成事件冒泡~ 3.2 e.nativeEvent.stopImmediatePropagation该方法可以阻止监听同一事件的其他事件监听器被调用。 在 React 中,一个组件只能绑定一个同类型的事件监听器,当重复定义时,后面的监听器会覆盖之前的。 事实上 nativeEvent 的 stopImmediatePropagation只能阻止绑定在 document 上的事件监听器。而合成事件上的 e.nativeEvent.stopImmediatePropagation() 能阻止合成事件不会冒泡到 document 上。举一个实际案例:实现点击空白处关闭菜单的功能: 当菜单打开时,在 document 上动态注册事件,用来关闭菜单。点击菜单内部,由于不冒泡,会正常执行菜单点击。点击菜单外部,执行document上事件,关闭菜单。在菜单关闭的一刻,在 document 上移除该事件,这样就不会重复执行该事件,浪费性能,也可以在 window 上注册事件,这样可以避开 document。 **4. 合成事件和原生事件是否可以混用合成事件和原生事件最好不要混用。 原生事件中如果执行了stopPropagation方法,则会导致其他React事件失效。因为所有元素的事件将无法冒泡到document上。 通过前面介绍的两者事件执行顺序来看,所有的 React 事件都将无法被注册。通过代码一起看看:class App extends React.Component<any, any> { parentRef: any; childRef: any; constructor(props: any) { super(props); this.parentRef = React.createRef(); } componentDidMount() { this.parentRef.current?.addEventListener("click", (e: any) => { e.stopPropagation(); console.log("阻止原生事件冒泡~"); }); document.addEventListener("click", (e) => { console.log("原生事件:document DOM 事件监听!"); }); } parentClickFun = (e: any) => { console.log("阻止合成事件冒泡~"); }; render() { return ( <div ref={this.parentRef} onClick={this.parentClickFun}> 点击测试“合成事件和原生事件是否可以混用” </div> ); } } export default App;输出结果:阻止原生事件冒泡~ 好了,本文就写到这里,建议大家可以再回去看下官方文档《合成事件》《事件处理》章节理解,有兴趣的朋友也可以阅读源码《React SyntheticEvent.js》。总结最后在回顾下本文学习目标:合成事件的概念和作用;合成事件与原生事件的 3 个区别;合成事件与原生事件的执行顺序;合成事件的事件池;合成事件 4 个常见问题。你是否都清楚了?欢迎一起讨论学习。参考文章1.《事件处理与合成事件(react)》2.官方文档《合成事件》《事件处理》3.《React合成事件和DOM原生事件混用须知》4.《React 合成事件系统之事件池》
一、背景介绍Leo 部门最近来了位前端实习生 Robin,作为师傅,Leo 认真的为 Robin 介绍了公司业务、部门工作等情况,还有前端的新人学习地图。 接下来 Robin 开始一周愉快的学习啦~ 一周后,Leo 为 Robin 同学布置了考试作业,开发一个人员搜索选择的页面,效果大致如下:Robin 看完这个效果图后,一脸得意的样子,这确实不难呀~过几天后,Robin 带着自己写的代码,给 Leo 展示了她的代码,并疑惑的问到:她将这个“数组”输出到控制台:Leo 看了看代码:getUserList(){ const memberList = $('#MemberList li'); memberList.map(item => { console.log(item) }); console.log(memberList); }Leo 又问到:Robin 一脸疑惑,然后 Leo 再原来代码上,加了个 Array.from 方法如下:getUserList(){ const memberList = Array.from($('#MemberList li')); memberList.map(item => { console.log(item) }) console.log(memberList) }然后重新执行代码,输出下面结果:Leo 输出的结果,跟 Robin 说到:Robin 满脸期待望着师傅,对类数组对象更加充满期待。二、类数组对象介绍2.1 概念介绍所谓 类数组对象,即格式与数组结构类似,拥有 length 属性,可以通过索引来访问或设置里面的元素,但是不能使用数组的方法,就可以归类为类数组对象。举个例子🌰:const arrLike = { 0: 'name', 1: 'age', 2: 'job', length: 3 }2.2 常见类数组对象arguments 对象;function f() { return arguments; } f(1,2,3) // Arguments(3) [1, 2, 3, callee: ƒ, Symbol(Symbol.iterator): ƒ]NodeList(比如 document.getElementsByClassName('a') 得到的结果;document.getElementsByTagName('img') // HTMLCollection(3) [img, img, img]typedArray(比如 Int32Array);typedArray 即 类型化数组对象 是一种类似数组的对象,它提供了一种用于访问原始二进制数据的机制。JavaScript引擎会做一些内部优化,以便对数组的操作可以很快。然而,随着Web应用程序变得越来越强大,尤其一些新增加的功能例如:音频视频编辑,访问WebSockets的原始数据等,很明显有些时候如果使用JavaScript代码可以快速方便地通过类型化数组来操作原始的二进制数据将会非常有帮助。 —— 《MDN 类型化数组》const typedArray = new Uint8Array([1, 2, 3, 4]) // Uint8Array(4) [1, 2, 3, 4]另外使用 jQuery 获取元素,会被 jQuery 做特殊处理成为 init 类型:$('img') // init(3) [img, img, img, prevObject: init(1), context: document, selector: "img"]当然还有一些不常见的类数组对象,比如“Storage API 返回的结果”,这里就不一一列出。三、类数组对象属性下面通过 Robin 代码作为示例,介绍类数组对象的属性:const memberList = $('#MemberList li');3.1 读写// 读取 memberList[0]; // Node: <li>...</li> // 写入 memberList[0] = document.createElement("div") memberList[0]; // Node: <div>...</div>3.2 长度memberList.length; // 103.3 遍历for (let i = 0;i < memberList.length; i++){ console.log(memberList[i]); } /* Node: <li>...</li> Node: <li>...</li> ... 共10个,省略其他 */ memberList.map(item => console.log(item)); /* 0 ... 共10个,省略其他 */但如果是 HTMLCollection 就不能使用 map 咯:const img = document.getElementsByTagName("img"); img.map(item => console.log(item)); // Uncaught TypeError: img.map is not a function四、类数组对象处理Leo 看了看 Robin 处理这个列表的代码:getUserList(){ const memberList = $('#MemberList li'); const result = { text: [], dom : [], }; memberList.map(item => { item = memberList[item] // 判断当前节点是否有 checked 类名 }) console.log(result) this.showToast(`选中成员:${result.text}`); }很明显,Robin 并没有对 jQuery 获取到的 memberList 做处理,直接使用,通过索引来获取对应值。 Leo 继续和 Robin 介绍到: 4.1 Array.from使用 Array.from 来将类数组对象转为数组对象,操作起来非常简单:getUserList(){ const memberList = Array.from($('#MemberList li')); // 省略其他代码 }其语法如下:Array.from(arrayLike[, mapFn[, thisArg]])参数:arrayLike 想要转换成数组的伪数组对象或可迭代对象。mapFn 可选如果指定了该参数,新数组中的每个元素会执行该回调函数。thisArg 可选可选参数,执行回调函数 mapFn 时 this 对象。返回值: 一个新的数组实例。更多 Array.from 介绍可以查看文档。4.2 Array.prototype.slice.call()slice() 方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。实现代码:getUserList(){ const memberList = Array.prototype.slice.call($('#MemberList li')); // 省略其他代码 }更多 Array.prototype.slice 介绍可以查看文档。4.3 ES6展开运算符展开语法(Spread syntax), 可以在函数调用/数组构造时, 将数组表达式或者string在语法层面展开;还可以在构造字面量对象时, 将对象表达式按key-value的方式展开。实现代码:getUserList(){ const memberList = [...document.getElementsByTagName("li")]; // 省略其他代码 }更多 ES6展开运算符 介绍可以查看文档。4.4 利用concat+applygetUserList(){ const memberList = Array.prototype.concat.apply([], $('#MemberList li')); // 省略其他代码 }五、案例小结Leo 介绍完这些知识后,Robin 又优化了下自己的代码,涉及到类数组对象操作的核心 js 代码如下:class SelectMember { constructor(){ this.MockUsers = window.MockUsers; this.init(); } init(){ this.initMemberList('#MemberList', this.MockUsers); this.initBindEvent(); } // ... 省略部分代码,保留核心代码 submitSelect(){ const memberList = Array.from($('#MemberList li')); const result = { text: [], dom : [], }; memberList.map(item => { const hasClass = $(item).children('.round-checkbox').children('span').hasClass(this.selectClassName); if(hasClass){ result.text.push($(item).children('.user-data').children('h4').text()); result.dom.push(item); } }) this.showToast(`选中成员:${result.text}`); } } let newMember = new SelectMember();很明显,使用正确方式来处理类数组对象,不仅能使我们代码更加少,减少转换处理,还能提高代码质量。整个项目的完整代码,可以在我的 github 查看:github.com/pingan8787/…六、总结本文我们通过一个实际场景,详细介绍了类数组对象在实际开发中的使用,对于常见的类数组对象,我们还介绍了处理方式,能很大程度减少我们处理类数组对象的操作,将类数组统一转成数组,更加方便对数据的操作。 希望看完本文的你,以后再遇到类数组对象,不会再一脸懵逼咯~~~
五、实战示例1. 简单示例定义发布者接口(Publisher)、事件总线接口(EventChannel)和订阅者接口(Subscriber):interface Publisher<T> { subscriber: string; data: T; } interface EventChannel<T> { on : (subscriber: string, callback: () => void) => void; off : (subscriber: string, callback: () => void) => void; emit: (subscriber: string, data: T) => void; } interface Subscriber { subscriber: string; callback: () => void; } // 方便后面使用 interface PublishData { [key: string]: string; }实现具体发布者类(ConcretePublisher):class ConcretePublisher<T> implements Publisher<T> { public subscriber: string = ""; public data: T; constructor(subscriber: string, data: T) { this.subscriber = subscriber; this.data = data; } }实现具体事件总线类(ConcreteEventChannel):class ConcreteEventChannel<T> implements EventChannel<T> { // 初始化订阅者对象 private subjects: { [key: string]: Function[] } = {}; // 实现添加订阅事件 public on(subscriber: string, callback: () => void): void { console.log(`收到订阅信息,订阅事件:${subscriber}`); if (!this.subjects[subscriber]) { this.subjects[subscriber] = []; } this.subjects[subscriber].push(callback); }; // 实现取消订阅事件 public off(subscriber: string, callback: () => void): void { console.log(`收到取消订阅请求,需要取消的订阅事件:${subscriber}`); if (callback === null) { this.subjects[subscriber] = []; } else { const index: number = this.subjects[subscriber].indexOf(callback); ~index && this.subjects[subscriber].splice(index, 1); } }; // 实现发布订阅事件 public emit (subscriber: string, data: T): void { console.log(`收到发布者信息,执行订阅事件:${subscriber}`); this.subjects[subscriber].forEach(item => item(data)); }; }实现具体订阅者类(ConcreteSubscriber):class ConcreteSubscriber implements Subscriber { public subscriber: string = ""; constructor(subscriber: string, callback: () => void) { this.subscriber = subscriber; this.callback = callback; } public callback(): void { }; }运行示例代码:interface Publisher<T> { subscriber: string; data: T; } interface EventChannel<T> { on : (subscriber: string, callback: () => void) => void; off : (subscriber: string, callback: () => void) => void; emit: (subscriber: string, data: T) => void; } interface Subscriber { subscriber: string; callback: () => void; } interface PublishData { [key: string]: string; } class ConcreteEventChannel<T> implements EventChannel<T> { // 初始化订阅者对象 private subjects: { [key: string]: Function[] } = {}; // 实现添加订阅事件 public on(subscriber: string, callback: () => void): void { console.log(`收到订阅信息,订阅事件:${subscriber}`); if (!this.subjects[subscriber]) { this.subjects[subscriber] = []; } this.subjects[subscriber].push(callback); }; // 实现取消订阅事件 public off(subscriber: string, callback: () => void): void { console.log(`收到取消订阅请求,需要取消的订阅事件:${subscriber}`); if (callback === null) { this.subjects[subscriber] = []; } else { const index: number = this.subjects[subscriber].indexOf(callback); ~index && this.subjects[subscriber].splice(index, 1); } }; // 实现发布订阅事件 public emit (subscriber: string, data: T): void { console.log(`收到发布者信息,执行订阅事件:${subscriber}`); this.subjects[subscriber].forEach(item => item(data)); }; } class ConcretePublisher<T> implements Publisher<T> { public subscriber: string = ""; public data: T; constructor(subscriber: string, data: T) { this.subscriber = subscriber; this.data = data; } } class ConcreteSubscriber implements Subscriber { public subscriber: string = ""; constructor(subscriber: string, callback: () => void) { this.subscriber = subscriber; this.callback = callback; } public callback(): void { }; } /* 运行示例 */ const pingan8787 = new ConcreteSubscriber( "running", () => { console.log("订阅者 pingan8787 订阅事件成功!执行回调~"); } ); const leo = new ConcreteSubscriber( "swimming", () => { console.log("订阅者 leo 订阅事件成功!执行回调~"); } ); const lisa = new ConcreteSubscriber( "swimming", () => { console.log("订阅者 lisa 订阅事件成功!执行回调~"); } ); const pual = new ConcretePublisher<PublishData>( "swimming", {message: "pual 发布消息~"} ); const eventBus = new ConcreteEventChannel<PublishData>(); eventBus.on(pingan8787.subscriber, pingan8787.callback); eventBus.on(leo.subscriber, leo.callback); eventBus.on(lisa.subscriber, lisa.callback); // 发布者 pual 发布 "swimming"相关的事件 eventBus.emit(pual.subscriber, pual.data); eventBus.off (lisa.subscriber, lisa.callback); eventBus.emit(pual.subscriber, pual.data); /* 输出结果: [LOG]: 收到订阅信息,订阅事件:running [LOG]: 收到订阅信息,订阅事件:swimming [LOG]: 收到订阅信息,订阅事件:swimming [LOG]: 收到发布者信息,执行订阅事件:swimming [LOG]: 订阅者 leo 订阅事件成功!执行回调~ [LOG]: 订阅者 lisa 订阅事件成功!执行回调~ [LOG]: 收到取消订阅请求,需要取消的订阅事件:swimming [LOG]: 收到发布者信息,执行订阅事件:swimming [LOG]: 订阅者 leo 订阅事件成功!执行回调~ */完整代码如下:interface Publisher { subscriber: string; data: any; } interface EventChannel { on : (subscriber: string, callback: () => void) => void; off : (subscriber: string, callback: () => void) => void; emit: (subscriber: string, data: any) => void; } interface Subscriber { subscriber: string; callback: () => void; } class ConcreteEventChannel implements EventChannel { // 初始化订阅者对象 private subjects: { [key: string]: Function[] } = {}; // 实现添加订阅事件 public on(subscriber: string, callback: () => void): void { console.log(`收到订阅信息,订阅事件:${subscriber}`); if (!this.subjects[subscriber]) { this.subjects[subscriber] = []; } this.subjects[subscriber].push(callback); }; // 实现取消订阅事件 public off(subscriber: string, callback: () => void): void { console.log(`收到取消订阅请求,需要取消的订阅事件:${subscriber}`); if (callback === null) { this.subjects[subscriber] = []; } else { const index: number = this.subjects[subscriber].indexOf(callback); ~index && this.subjects[subscriber].splice(index, 1); } }; // 实现发布订阅事件 public emit (subscriber: string, data = null): void { console.log(`收到发布者信息,执行订阅事件:${subscriber}`); this.subjects[subscriber].forEach(item => item(data)); }; } class ConcretePublisher implements Publisher { public subscriber: string = ""; public data: any; constructor(subscriber: string, data: any) { this.subscriber = subscriber; this.data = data; } } class ConcreteSubscriber implements Subscriber { public subscriber: string = ""; constructor(subscriber: string, callback: () => void) { this.subscriber = subscriber; this.callback = callback; } public callback(): void { }; } /* 运行示例 */ const pingan8787 = new ConcreteSubscriber( "running", () => { console.log("订阅者 pingan8787 订阅事件成功!执行回调~"); } ); const leo = new ConcreteSubscriber( "swimming", () => { console.log("订阅者 leo 订阅事件成功!执行回调~"); } ); const lisa = new ConcreteSubscriber( "swimming", () => { console.log("订阅者 lisa 订阅事件成功!执行回调~"); } ); const pual = new ConcretePublisher( "swimming", {message: "pual 发布消息~"} ); const eventBus = new ConcreteEventChannel(); eventBus.on(pingan8787.subscriber, pingan8787.callback); eventBus.on(leo.subscriber, leo.callback); eventBus.on(lisa.subscriber, lisa.callback); // 发布者 pual 发布 "swimming"相关的事件 eventBus.emit(pual.subscriber, pual.data); eventBus.off (lisa.subscriber, lisa.callback); eventBus.emit(pual.subscriber, pual.data); /* 输出结果: [LOG]: 收到订阅信息,订阅事件:running [LOG]: 收到订阅信息,订阅事件:swimming [LOG]: 收到订阅信息,订阅事件:swimming [LOG]: 收到发布者信息,执行订阅事件:swimming [LOG]: 订阅者 leo 订阅事件成功!执行回调~ [LOG]: 订阅者 lisa 订阅事件成功!执行回调~ [LOG]: 收到取消订阅请求,需要取消的订阅事件:swimming [LOG]: 收到发布者信息,执行订阅事件:swimming [LOG]: 订阅者 leo 订阅事件成功!执行回调~ */2. Vue.js 使用示例参考文章:《Vue事件总线(EventBus)使用详细介绍》 。2.1 创建 event bus在 Vue.js 中创建 EventBus 有两种方式:手动实现,导出 Vue 实例化的结果。// event-bus.js import Vue from 'vue' export const EventBus = new Vue();直接在项目中的 main.js全局挂载 Vue 实例化的结果。// main.js Vue.prototype.$EventBus = new Vue()2.2 发送事件假设你有两个Vue页面需要通信: A 和 B ,A页面按钮上绑定了点击事件,发送一则消息,通知 B 页面。<!-- A.vue --> <template> <button @click="sendMsg()">-</button> </template> <script> import { EventBus } from "../event-bus.js"; export default { methods: { sendMsg() { EventBus.$emit("aMsg", '来自A页面的消息'); } } }; </script>2.3 接收事件B 页面中接收消息,并展示内容到页面上。<!-- IncrementCount.vue --> <template> <p>{{msg}}</p> </template> <script> import { EventBus } from "../event-bus.js"; export default { data(){ return { msg: '' } }, mounted() { EventBus.$on("aMsg", (msg) => { // A发送来的消息 this.msg = msg; }); } }; </script>同理可以从 B 页面往 A 页面发送消息,使用下面方法:// 发送消息 EventBus.$emit(channel: string, callback(payload1,…)) // 监听接收消息 EventBus.$on(channel: string, callback(payload1,…))2.4 移除事件监听者使用 EventBus.$off('aMsg') 来移除应用内所有对此某个事件的监听。或者直接用 EventBus.$off() 来移除所有事件频道,不需要添加任何参数 。import { eventBus } from './event-bus.js' EventBus.$off('aMsg', {})六、总结观察者模式和发布-订阅模式的差别在于事件总线,如果有则是发布-订阅模式,反之为观察者模式。所以在实现发布-订阅模式,关键在于实现这个事件总线,在某个特定时间触发某个特定事件,从而触发监听这个特定事件的组件进行相应操作的功能。发布-订阅模式在很多时候非常有用。参考文章1.《发布/订阅》2.《观察者模式VS订阅发布模式》
前言在之前两篇自测清单中,和大家分享了很多 JavaScript 基础知识,大家可以一起再回顾下~本文是我在我们团队内部“现代 JavaScript 突击队”分享的一篇内容,第二期学习内容为“设计模式”系列,我会将我负责分享的知识整理成文章输出,希望能够和大家一起温故知新!“现代 JavaScript 突击队”学习总结:《初中级前端 JavaScript 自测清单 - 1》《初中级前端 JavaScript 自测清单 - 2》《TypeScript 设计模式之观察者模式》《TypeScript语法总结+项目(Vue.js+TS)实战》一、模式介绍1. 生活场景最近刚毕业的学生 Leo 准备开始租房了,他来到房产中介,跟中介描述了自己的租房需求,开开心心回家了。第二天,中介的小哥哥小姐姐为 Leo 列出符他需求的房间,并打电话约他一起看房了,最后 Leo 选中一套满意的房间,高高兴兴过去签合同,准备开始新生活~还有个大佬 Paul,准备将手中 10 套房出租出去,于是他来到房产中介,在中介那边提供了自己要出租的房间信息,沟通好手续费,开开心心回家了。第二天,Paul 接到中介的好消息,房子租出去了,于是他高高兴兴过去签合同,开始收房租了~上面场景有个需要特别注意的地方:租户在租房过程中,不知道房间具体房东是谁,到后面签合同才知道;房东在出租过程中,不知道房间具体租户是谁,到后面签合同才知道;这两点其实就是后面要介绍的 发布-订阅模式 的一个核心特点。2. 概念介绍在软件架构中,发布-订阅模式是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。发布-订阅是消息队列范式的兄弟,通常是更大的面向消息中间件系统的一部分。大多数消息系统在API中同时支持消息队列模型和发布/订阅模型,例如Java消息服务(JMS)。这种模式提供了更大的网络可扩展性和更动态的网络拓扑,同时也降低了对发布者和发布数据的结构修改的灵活性。二、 观察者模式 vs 发布-订阅模式看完上面概念,有没有觉得与观察者模式很像? 但其实两者还是有差异的,接下来一起看看。1. 概念对比我们分别为通过两种实际生活场景来介绍这两种模式:观察者模式:如微信中 顾客-微商 关系;发布-订阅模式:如淘宝购物中 顾客-淘宝-商家 关系。这两种场景的过程分别是这样:1.1 观察者模式 观察者模式中,消费顾客关注(如加微信好友)自己有兴趣的微商,微商就会私聊发自己在卖的产品给消费顾客。 这个过程中,消费顾客相当于观察者(Observer),微商相当于观察目标(Subject)。1.2 发布-订阅模式接下来看看 发布-订阅模式 : 在 发布-订阅模式 中,消费顾客通过淘宝搜索自己关注的产品,商家通过淘宝发布商品,当消费顾客在淘宝搜索的产品,已经有商家发布,则淘宝会将对应商品推荐给消费顾客。 这个过程中,消费顾客相当于订阅者,淘宝相当于事件总线,商家相当于发布者。2. 流程对比3. 小结所以可以看出,观察者模式和发布-订阅模式差别在于有没有一个中央的事件总线。如果有,我们就可以认为这是个发布-订阅模式。如果没有,那么就可以认为是观察者模式。因为其实它们都实现了一个关键的功能:发布事件-订阅事件并触发事件。三、模式特点对比完观察者模式和发布-订阅模式后,我们大致理解发布-订阅模式是什么了。接着总结下该模式的特点:1. 模式组成在发布-订阅模式中,通常包含以下角色:发布者:Publisher事件总线:Event Channel订阅者:Subscriber2. UML 类图3. 优点松耦合(Independence)发布-订阅模式可以将众多需要通信的子系统(Subsystem)解耦,每个子系统独立管理。而且即使部分子系统取消订阅,也不会影响事件总线的整体管理。 发布-订阅模式中每个应用程序都可以专注于其核心功能,而事件总线负责将消息路由到每个订阅者手里。高伸缩性(Scalability)发布-订阅模式增加了系统的可伸缩性,提高了发布者的响应能力。原因是发布者(Publisher)可以快速地向输入通道发送一条消息,然后返回到其核心处理职责,而不必等待子系统处理完成。然后事件总线负责确保把消息传递到每个订阅者(Subscriber)手里。高可靠性(Reliability)发布-订阅模式提高了可靠性。异步的消息传递有助于应用程序在增加的负载下继续平稳运行,并且可以更有效地处理间歇性故障。灵活性(Flexibility)你不需要关心不同的组件是如何组合在一起的,只要他们共同遵守一份协议即可。 发布-订阅模式允许延迟处理或者按计划的处理。例如当系统负载大的时候,订阅者可以等到非高峰时间才接收消息,或者根据特定的计划处理消息。4. 缺点在创建订阅者本身会消耗内存,但当订阅消息后,没有进行发布,而订阅者会一直保存在内存中,占用内存;创建订阅者需要消耗一定的时间和内存。如果过度使用的话,反而使代码不好理解及代码不好维护。四、使用场景如果我们项目中很少使用到订阅者,或者与子系统实时交互较少,则不适合 发布-订阅模式 。 在以下情况下可以考虑使用此模式:应用程序需要向大量消费者广播信息。例如微信订阅号就是一个消费者量庞大的广播平台。应用程序需要与一个或多个独立开发的应用程序或服务通信,这些应用程序或服务可能使用不同的平台、编程语言和通信协议。应用程序可以向消费者发送信息,而不需要消费者的实时响应。
前言在之前两篇自测清单中,和大家分享了很多 JavaScript 基础知识,大家可以一起再回顾下~本文是我在我们团队内部“「现代 JavaScript 突击队」”分享的一篇内容,第二期学习内容为“「设计模式」”系列,我会将我负责分享的知识整理成文章输出,希望能够和大家一起温故知新!“「现代 JavaScript 突击队」”学习总结:《初中级前端 JavaScript 自测清单 - 1》《初中级前端 JavaScript 自测清单 - 2》一、模式介绍1. 背景介绍在软件系统中经常碰到这类需求:当一个对象的状态发生改变,某些与它相关的对象也要随之做出相应的变化。这是建立一种「对象与对象之间的依赖关系」,一个对象发生改变时将「自动通知其他对象」,其他对象将「相应做出反应」。我们将发生改变的对象称为「观察目标」,将被通知的对象称为「观察者」,「一个观察目标可以对应多个观察者」,而且这些观察者之间没有相互联系,之后可以根据需要增加和删除观察者,使得系统更易于扩展,这就是观察者模式的产生背景。2. 概念介绍观察者模式(Observer Pattern):定义对象间的一种「一对多依赖关系」,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。观察者模式是一种对象行为型模式。3. 生活场景在所有浏览器事件(鼠标悬停,按键等事件)都是观察者模式的例子。另外还有:如我们订阅微信公众号“前端自习课”(「观察目标」),当“前端自习课”群发图文消息后,所有公众号粉丝(「观察者」)都会接收到这篇文章(事件),这篇文章的内容是发布者自定义的(自定义事件),粉丝阅读后作出特定操作(如:点赞,收藏,关注等)。观察者模式.png二、模式特点1. 模式组成在观察者模式中,通常包含以下角色:「目标:Subject」「观察目标:ConcreteSubject」「观察者:Observer」「具体观察者:ConcreteObserver」2. UML 类图UML 类图图片来源:《TypeScript 设计模式之观察者模式》3. 优点观察者模式可以实现「表示层和数据逻辑层的分离」,并「降低观察目标和观察者之间耦合度」;观察者模式支持「简单广播通信」,「自动通知」所有已经订阅过的对象;观察者模式「符合“开闭原则”的要求」;观察目标和观察者之间的抽象耦合关系能够「单独扩展以及重用」。4. 缺点当一个观察目标「有多个直接或间接的观察者」时,通知所有观察者的过程将会花费很多时间;当观察目标和观察者之间存在「循环依赖」时,观察目标会触发它们之间进行循环调用,可能「导致系统崩溃」。观察者模式缺少相应机制,让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。三、使用场景在以下情况下可以使用观察者模式:在一个抽象模型中,一个对象的行为「依赖于」另一个对象的状态。即当「目标对象」的状态发生改变时,会直接影响到「观察者」的行为;一个对象需要通知其他对象发生反应,但不知道这些对象是谁。需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。四、实战示例1. 简单示例定义「观察目标接口」(Subject)和「观察者接口」(Observer)// ObserverPattern.ts // 观察目标接口 interface Subject { addObserver: (observer: Observer) => void; deleteObserver: (observer: Observer) => void; notifyObservers: () => void; } // 观察者接口 interface Observer { notify: () => void; }定义「具体观察目标类」(ConcreteSubject)// ObserverPattern.ts // 具体观察目标类 class ConcreteSubject implements Subject{ private observers: Observer[] = []; // 添加观察者 public addObserver(observer: Observer): void { console.log(observer, " is pushed~~"); this.observers.push(observer); } // 移除观察者 public deleteObserver(observer: Observer): void { console.log(observer, " have deleted~~"); const idx: number = this.observers.indexOf(observer); ~idx && this.observers.splice(idx, 1); } // 通知观察者 public notifyObservers(): void { console.log("notify all the observers ", this.observers); this.observers.forEach(observer => { // 调用 notify 方法时可以携带指定参数 observer.notify(); }); } }定义「具体观察者类」(ConcreteObserver)// ObserverPattern.ts // 具体观 class ConcreteObserver implements Observer{ constructor(private name: string) {} notify(): void { // 可以处理其他逻辑 console.log(`${this.name} has been notified.`); } }测试代码// ObserverPattern.ts function useObserver(): void { const subject: Subject = new ConcreteSubject(); const Leo = new ConcreteObserver("Leo"); const Robin = new ConcreteObserver("Robin"); const Pual = new ConcreteObserver("Pual"); const Lisa = new ConcreteObserver("Lisa"); subject.addObserver(Leo); subject.addObserver(Robin); subject.addObserver(Pual); subject.addObserver(Lisa); subject.notifyObservers(); subject.deleteObserver(Pual); subject.deleteObserver(Lisa); subject.notifyObservers(); } useObserver();完整演示代码如下:// ObserverPattern.ts interface Subject { addObserver: (observer: Observer) => void; deleteObserver: (observer: Observer) => void; notifyObservers: () => void; } interface Observer { notify: () => void; } class ConcreteSubject implements Subject{ private observers: Observer[] = []; public addObserver(observer: Observer): void { console.log(observer, " is pushed~~"); this.observers.push(observer); } public deleteObserver(observer: Observer): void { console.log(observer, " have deleted~~"); const idx: number = this.observers.indexOf(observer); ~idx && this.observers.splice(idx, 1); } public notifyObservers(): void { console.log("notify all the observers ", this.observers); this.observers.forEach(observer => { // 调用 notify 方法时可以携带指定参数 observer.notify(); }); } } class ConcreteObserver implements Observer{ constructor(private name: string) {} notify(): void { // 可以处理其他逻辑 console.log(`${this.name} has been notified.`); } } function useObserver(): void { const subject: Subject = new ConcreteSubject(); const Leo = new ConcreteObserver("Leo"); const Robin = new ConcreteObserver("Robin"); const Pual = new ConcreteObserver("Pual"); const Lisa = new ConcreteObserver("Lisa"); subject.addObserver(Leo); subject.addObserver(Robin); subject.addObserver(Pual); subject.addObserver(Lisa); subject.notifyObservers(); subject.deleteObserver(Pual); subject.deleteObserver(Lisa); subject.notifyObservers(); } useObserver();2. Vue.js 数据双向绑定实现原理在 Vue.js 中,当我们修改数据状时,视图随之更新,这就是 Vue.js 的双向数据绑定(也称响应式原理),这是 Vue.js 中最独特的特性之一。 如果你对 Vue.js 的双向数据绑定还不清楚,建议先阅读官方文档《深入响应式原理》章节。2.1 原理介绍在官网中提供这么一张流程图,介绍了 Vue.js 响应式系统的整个流程: 图片来自:Vue.js 官网《深入响应式原理》在 Vue.js 中,每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”(“Touch” 过程)过的数据 property 记录为依赖(Collect as Dependency 过程)。之后当依赖项的 setter 触发时,会通知 watcher(Notify 过程),从而使它关联的组件重新渲染(Trigger re-render 过程)——这是一个典型的观察者模式。这道面试题考察面试者对 Vue.js 底层原理的理解、对观察者模式的实现能力以及一系列重要的JS知识点,具有较强的综合性和代表性。2.2 组成部分在 Vue.js 数据双向绑定的实现逻辑中,包含三个关键角色:observer(监听器):这里的 observer 不仅是订阅者(「需要监听数据变化」),同时还是发布者(「对监听的数据进行转发」)。watcher(订阅者):watcher对象是**真正的订阅者, **observer 把数据转发给 watcher 对象。watcher 接收到新的数据后,执行视图更新。compile(编译器):MVVM 框架特有的角色,负责对每个节点元素指令进行扫描和解析,处理指令的数据初始化、订阅者的创建等操作。这三者的配合过程如图所示: 图片来自:掘金小册《JavaScript 设计模式核⼼原理与应⽤实践》2.3 实现核心代码 observer首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的 getter 和 setter 函数。这样但凡这个对象的某个属性发生了改变,就会触发 setter 函数,进而通知到订阅者。这个 setter 函数,就是我们的监听器:// observe方法遍历并包装对象属性 function observe(target) { // 若target是一个对象,则遍历它 if(target && typeof target === 'object') { Object.keys(target).forEach((key)=> { // defineReactive方法会给目标属性装上“监听器” defineReactive(target, key, target[key]) }) } } // 定义defineReactive方法 function defineReactive(target, key, val) { // 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历 observe(val) // 为当前属性安装监听器 Object.defineProperty(target, key, { // 可枚举 enumerable: true, // 不可配置 configurable: false, get: function () { return val; }, // 监听器函数 set: function (value) { console.log(`${target}属性的${key}属性从${val}值变成了了${value}`) val = value } }); }下面实现订阅者 Dep:// 定义订阅者类Dep class Dep { constructor() { // 初始化订阅队列 this.subs = [] } // 增加订阅者 addSub(sub) { this.subs.push(sub) } // 通知订阅者(是不是所有的代码都似曾相识?) notify() { this.subs.forEach((sub)=>{ sub.update() }) } }现在我们可以改写 defineReactive 中的 setter 方法,在监听器里去通知订阅者了:function defineReactive(target, key, val) { const dep = new Dep() // 监听当前属性 observe(val) Object.defineProperty(target, key, { set: (value) => { // 通知所有订阅者 dep.notify() } }) }五、总结观察者模式又称发布-订阅模式、模型-视图模式、源-监听器模式或从属者模式。是一种「对象行为型模式」。其定义了一种「对象间的一对多依赖关系」,当观察目标发生状态变化,会通知所有观察者对象,使它们自动更新。在实际业务中,如果一个对象的行为「依赖于」另一个对象的状态。或者说当「目标对象」的状态发生改变时,会直接影响到「观察者」的行为,尽量考虑到使用观察者模式来实现。六、拓展观察者模式和发布-订阅模式两者很像,但其实区别比较大。例如:耦合度差异:观察者模式的耦合度就比发布-订阅模式要高;关注点不同:观察者模式需要知道彼此的存在,而发布-订阅模式则是通过调度中心来联系发布/订阅者。下一篇文章见。参考文章1.《3. 观察者模式》 2.《TypeScript 设计模式之观察者模式》 3.《JavaScript 设计模式核⼼原理与应⽤实践》
前言在之前两篇自测清单中,和大家分享了很多 JavaScript 基础知识,大家可以一起再回顾下~本文是我在我们团队内部“现代 JavaScript 突击队”分享的一篇内容,第二期学习内容为“设计模式”系列,我会将我负责分享的知识整理成文章输出,希望能够和大家一起温故知新!“现代 JavaScript 突击队”学习总结:《初中级前端 JavaScript 自测清单 - 1》《初中级前端 JavaScript 自测清单 - 2》一、模式介绍1. 背景介绍在软件系统中经常碰到这类需求:当一个对象的状态发生改变,某些与它相关的对象也要随之做出相应的变化。这是建立一种对象与对象之间的依赖关系,一个对象发生改变时将自动通知其他对象,其他对象将相应做出反应。我们将发生改变的对象称为观察目标,将被通知的对象称为观察者,一个观察目标可以对应多个观察者,而且这些观察者之间没有相互联系,之后可以根据需要增加和删除观察者,使得系统更易于扩展,这就是观察者模式的产生背景。2. 概念介绍观察者模式(Observer Pattern):定义对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。观察者模式是一种对象行为型模式。3. 生活场景在所有浏览器事件(鼠标悬停,按键等事件)都是观察者模式的例子。另外还有:如我们订阅微信公众号“前端自习课”(观察目标),当“前端自习课”群发图文消息后,所有公众号粉丝(观察者)都会接收到这篇文章(事件),这篇文章的内容是发布者自定义的(自定义事件),粉丝阅读后作出特定操作(如:点赞,收藏,关注等)。二、模式特点1. 模式组成在观察者模式中,通常包含以下角色:目标:Subject观察目标:ConcreteSubject观察者:Observer具体观察者:ConcreteObserver2. UML 类图图片来源:《TypeScript 设计模式之观察者模式》 3. 优点观察者模式可以实现表示层和数据逻辑层的分离,并降低观察目标和观察者之间耦合度;观察者模式支持简单广播通信,自动通知所有已经订阅过的对象;观察者模式符合“开闭原则”的要求;观察目标和观察者之间的抽象耦合关系能够单独扩展以及重用。4. 缺点当一个观察目标有多个直接或间接的观察者时,通知所有观察者的过程将会花费很多时间;当观察目标和观察者之间存在循环依赖时,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。观察者模式缺少相应机制,让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。三、使用场景在以下情况下可以使用观察者模式:在一个抽象模型中,一个对象的行为依赖于另一个对象的状态。即当目标对象的状态发生改变时,会直接影响到观察者的行为;一个对象需要通知其他对象发生反应,但不知道这些对象是谁。需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。四、实战示例1. 简单示例定义观察目标接口(Subject)和观察者接口(Observer)// ObserverPattern.ts // 观察目标接口 interface Subject { addObserver: (observer: Observer) => void; deleteObserver: (observer: Observer) => void; notifyObservers: () => void; } // 观察者接口 interface Observer { notify: () => void; }定义具体观察目标类(ConcreteSubject)// ObserverPattern.ts // 具体观察目标类 class ConcreteSubject implements Subject{ private observers: Observer[] = []; // 添加观察者 public addObserver(observer: Observer): void { console.log(observer, " is pushed~~"); this.observers.push(observer); } // 移除观察者 public deleteObserver(observer: Observer): void { console.log(observer, " have deleted~~"); const idx: number = this.observers.indexOf(observer); ~idx && this.observers.splice(idx, 1); } // 通知观察者 public notifyObservers(): void { console.log("notify all the observers ", this.observers); this.observers.forEach(observer => { // 调用 notify 方法时可以携带指定参数 observer.notify(); }); } }定义具体观察者类(ConcreteObserver)// ObserverPattern.ts // 具体观 class ConcreteObserver implements Observer{ constructor(private name: string) {} notify(): void { // 可以处理其他逻辑 console.log(`${this.name} has been notified.`); } }测试代码// ObserverPattern.ts function useObserver(): void { const subject: Subject = new ConcreteSubject(); const Leo = new ConcreteObserver("Leo"); const Robin = new ConcreteObserver("Robin"); const Pual = new ConcreteObserver("Pual"); const Lisa = new ConcreteObserver("Lisa"); subject.addObserver(Leo); subject.addObserver(Robin); subject.addObserver(Pual); subject.addObserver(Lisa); subject.notifyObservers(); subject.deleteObserver(Pual); subject.deleteObserver(Lisa); subject.notifyObservers(); } useObserver();完整演示代码如下:// ObserverPattern.ts interface Subject { addObserver: (observer: Observer) => void; deleteObserver: (observer: Observer) => void; notifyObservers: () => void; } interface Observer { notify: () => void; } class ConcreteSubject implements Subject{ private observers: Observer[] = []; public addObserver(observer: Observer): void { console.log(observer, " is pushed~~"); this.observers.push(observer); } public deleteObserver(observer: Observer): void { console.log(observer, " have deleted~~"); const idx: number = this.observers.indexOf(observer); ~idx && this.observers.splice(idx, 1); } public notifyObservers(): void { console.log("notify all the observers ", this.observers); this.observers.forEach(observer => { // 调用 notify 方法时可以携带指定参数 observer.notify(); }); } } class ConcreteObserver implements Observer{ constructor(private name: string) {} notify(): void { // 可以处理其他逻辑 console.log(`${this.name} has been notified.`); } } function useObserver(): void { const subject: Subject = new ConcreteSubject(); const Leo = new ConcreteObserver("Leo"); const Robin = new ConcreteObserver("Robin"); const Pual = new ConcreteObserver("Pual"); const Lisa = new ConcreteObserver("Lisa"); subject.addObserver(Leo); subject.addObserver(Robin); subject.addObserver(Pual); subject.addObserver(Lisa); subject.notifyObservers(); subject.deleteObserver(Pual); subject.deleteObserver(Lisa); subject.notifyObservers(); } useObserver();2. Vue.js 数据双向绑定实现原理在 Vue.js 中,当我们修改数据状时,视图随之更新,这就是 Vue.js 的双向数据绑定(也称响应式原理),这是 Vue.js 中最独特的特性之一。 如果你对 Vue.js 的双向数据绑定还不清楚,建议先阅读官方文档《深入响应式原理》章节。2.1 原理介绍在官网中提供这么一张流程图,介绍了 Vue.js 响应式系统的整个流程: 图片来自:Vue.js 官网《深入响应式原理》在 Vue.js 中,每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”(“Touch” 过程)过的数据 property 记录为依赖(Collect as Dependency 过程)。之后当依赖项的 setter 触发时,会通知 watcher(Notify 过程),从而使它关联的组件重新渲染(Trigger re-render 过程)——这是一个典型的观察者模式。这道面试题考察面试者对 Vue.js 底层原理的理解、对观察者模式的实现能力以及一系列重要的JS知识点,具有较强的综合性和代表性。2.2 组成部分在 Vue.js 数据双向绑定的实现逻辑中,包含三个关键角色:observer(监听器):这里的 observer 不仅是订阅者(需要监听数据变化),同时还是发布者(对监听的数据进行转发)。watcher(订阅者):watcher对象是**真正的订阅者, **observer 把数据转发给 watcher 对象。watcher 接收到新的数据后,执行视图更新。compile(编译器):MVVM 框架特有的角色,负责对每个节点元素指令进行扫描和解析,处理指令的数据初始化、订阅者的创建等操作。这三者的配合过程如图所示: 图片来自:掘金小册《JavaScript 设计模式核⼼原理与应⽤实践》2.3 实现核心代码 observer首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的 getter 和 setter 函数。这样但凡这个对象的某个属性发生了改变,就会触发 setter 函数,进而通知到订阅者。这个 setter 函数,就是我们的监听器:// observe方法遍历并包装对象属性 function observe(target) { // 若target是一个对象,则遍历它 if(target && typeof target === 'object') { Object.keys(target).forEach((key)=> { // defineReactive方法会给目标属性装上“监听器” defineReactive(target, key, target[key]) }) } } // 定义defineReactive方法 function defineReactive(target, key, val) { // 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历 observe(val) // 为当前属性安装监听器 Object.defineProperty(target, key, { // 可枚举 enumerable: true, // 不可配置 configurable: false, get: function () { return val; }, // 监听器函数 set: function (value) { console.log(`${target}属性的${key}属性从${val}值变成了了${value}`) val = value } }); }下面实现订阅者 Dep:// 定义订阅者类Dep class Dep { constructor() { // 初始化订阅队列 this.subs = [] } // 增加订阅者 addSub(sub) { this.subs.push(sub) } // 通知订阅者(是不是所有的代码都似曾相识?) notify() { this.subs.forEach((sub)=>{ sub.update() }) } }现在我们可以改写 defineReactive 中的 setter 方法,在监听器里去通知订阅者了:function defineReactive(target, key, val) { const dep = new Dep() // 监听当前属性 observe(val) Object.defineProperty(target, key, { set: (value) => { // 通知所有订阅者 dep.notify() } }) }五、总结观察者模式又称发布-订阅模式、模型-视图模式、源-监听器模式或从属者模式。是一种对象行为型模式。其定义了一种对象间的一对多依赖关系,当观察目标发生状态变化,会通知所有观察者对象,使它们自动更新。在实际业务中,如果一个对象的行为依赖于另一个对象的状态。或者说当目标对象的状态发生改变时,会直接影响到观察者的行为,尽量考虑到使用观察者模式来实现。六、拓展观察者模式和发布-订阅模式两者很像,但其实区别比较大。例如:耦合度差异:观察者模式的耦合度就比发布-订阅模式要高;关注点不同:观察者模式需要知道彼此的存在,而发布-订阅模式则是通过调度中心来联系发布/订阅者。下一篇文章见。参考文章1.《3. 观察者模式》2.《TypeScript 设计模式之观察者模式》 3.《JavaScript 设计模式核⼼原理与应⽤实践》
近期原创文章回顾😄《1.2w字 | 初中级前端 JavaScript 自测清单 - 1》《了不起的 Webpack HMR 学习指南(含源码分析)》《了不起的 Webpack 构建流程学习指南》《你不知道的 WeakMap》番外篇《你不知道的 Blob》番外篇《了不起的 tsconfig.json 指南》《200行JS代码,带你实现代码编译器》一、什么是 Scope HoistingScope Hoisting 是 webpack3 的新功能,直译为 "「作用域提升」",它可以让 webpack 打包出来的「代码文件更小」,「运行更快」。在 JavaScript 中,还有“变量提升”和“函数提升”,JavaScript 会将变量和函数的声明提升到当前作用域顶部,而“作用域提升”也类似,webpack 将引入到 JS 文件“提升到”它的引入者的顶部。首先回顾下在没有 Scope Hoisting 时用 webpack 打包下面两个文件:// main.js export default "hello leo~"; // index.js import str from "./main.js"; console.log(str);使用 webpack 打包后输出文件内容如下:[ (function (module, __webpack_exports__, __webpack_require__) { var __WEBPACK_IMPORTED_MODULE_0__main_js__ = __webpack_require__(1); console.log(__WEBPACK_IMPORTED_MODULE_0__main_js__["a"]); }), (function (module, __webpack_exports__, __webpack_require__) { __webpack_exports__["a"] = ('hello leo~'); }) ]再开启 Scope Hoisting 后,相同源码打包输出结果变为:[ (function (module, __webpack_exports__, __webpack_require__) { var main = ('hello leo~'); console.log(main); }) ]对比两种打包方式输出的代码,我们可以看出,启用 Scope Hoisting 后,函数声明变成一个, main.js 中定义的内容被直接注入到 main.js 对应模块中,这样做的好处:「代码体积更小」,因为函数申明语句会产生大量代码,导致包体积增大(模块越多越明显);代码在运行时因为创建的函数作用域更少,「内存开销也随之变小」。二、webpack 模块机制我们使用下面 webpack.config.js 配置,打包来看看 webpack 模块机制:// webpack.config.js const path = require('path'); module.exports = { entry: './src/index.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist'), }, mode: 'none', optimization: { usedExports: true, }, };打包后输出结果(精简后): 通过分析,我们可以得出以下结论:webpack 打包输出打是一个 IIFE(匿名闭包);modules 是一个数组,每一项是一个模块初始化函数;使用 __webpack_require() 来家在模块,返回 module.exports ;通过 __webpack_require__(__webpack_require__.s = 0); 启动程序。三、Scope Hoisting 原理Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能将打散的模块合并到一个函数中,前提是不能造成代码冗余。 因此「只有那些被引用了一次的模块才能被合并」。由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码「必须采用 ES6 模块化语句」,不然它将无法生效。 原因和4-10 使用 TreeShaking 中介绍的类似。四、Scope Hoisting 使用方式1. 自动启用在 webpack 的 mode 设置为 production 时,会默认自动启用 Scope Hooting。// webpack.config.js // ... module.exports = { // ... mode: "production" };2. 手动启用在 webpack 中已经内置 Scope Hoisting ,所以用起来很简单,只需要配置ModuleConcatenationPlugin 插件即可:// webpack.config.js // ... const webpack = require('webpack'); module.exports = { // ... plugins: [ new webpack.optimize.ModuleConcatenationPlugin() ] };考虑到 Scope Hoisting 以来 ES6 模块化语法,而现在很多 npm 包的第三方库还是使用 CommonJS 语法,为了充分发挥 Scope Hoisting 效果,我们可以增加以下 mainFields 配置:// webpack.config.js // ... const webpack = require('webpack'); module.exports = { // ... resolve: { // 针对 npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件 mainFields: ['jsnext:main', 'browser', 'main'] }, plugins: [ new webpack.optimize.ModuleConcatenationPlugin() ] }; 复制代码针对非 ES6 模块化语法的代码,webpack 会降级处理不使用 Scope Hoisting 优化,我们可以在 webpack 命令上增加 --display-optimization-bailout 参数,在输出的日志查看哪些代码做了降级处理:// package.json { // ... "scripts": { "build": "webpack --display-optimization-bailout" } }我们写个简单示例代码:// index.js import str from "./main.js"; const { name } = require('./no-es6.js'); // main.js export default "hello leo~"; // no-es6.js module.exports = { name : "leo" }接着打包测试,可以看到控制台输出下面日志:输出的日志中 ModuleConcatenation bailout 告诉我们哪些文件因为什么原因导致降级处理了。五、总结本文主要和大家一起回顾了 Scope Hoisting 基本概念,使用方式和使用后效果对比,希望大家不要只停留在会用 webpack,也要看看其中一些不常见的知识,比如本文介绍的 Scope Hoisting,它对我们项目优化非常有帮助,但平常又很少会去注意。六、参考文章《通过Scope Hoisting优化Webpack输出》《webpack 的 scope hoisting 是什么?》
九、alert / prompt / confirm1. alert显示一个警告对话框,上面显示有指定的文本内容以及一个“确定”按钮。 注意:弹出模态框,并暂停脚本,直到用户点击“确定”按钮。// 语法 window.alert(message); alert(message); // 示例 alert('hello leo!');message是要显示在对话框中的文本字符串,如果传入其他类型的值,会转换成字符串。2. prompt显示一个对话框,对话框中包含一条文字信息,用来提示用户输入文字。 注意:弹出模态框,并暂停脚本,直到用户点击“确定”按钮。 当点击确定返回文本,点击取消或按下 Esc 键返回 null。 语法如下:let result = window.prompt(text, value);result 用来存储用户输入文字的字符串,或者是 null。text 用来提示用户输入文字的字符串,如果没有任何提示内容,该参数可以省略不写。value 文本输入框中的默认值,该参数也可以省略不写。不过在 Internet Explorer 7 和 8 中,省略该参数会导致输入框中显示默认值"undefined"。3. confirmWindow.confirm() 方法显示一个具有一个可选消息和两个按钮(确定和取消)的模态对话框。 注意:弹出模态框,并暂停脚本,直到用户点击“确定”按钮。 语法如下:let result = window.confirm(message);message 是要在对话框中显示的可选字符串。result 是一个布尔值,表示是选择确定还是取消 (true表示OK)。十、条件运算符:if 和 '?'1. if 语句当 if 语句当条件表达式,会将表达式转换为布尔值,当为 truthy 时执行里面代码。 转换规则如:数字 0、空字符串 ""、null、undefined 和 NaN 都会被转换成 false。因为他们被称为 “falsy” 值。其他值被转换为 true,所以它们被称为 “truthy”。2. 三元运算符条件(三元)运算符是 JavaScript 仅有的使用三个操作数的运算符。一个条件后面会跟一个问号(?),如果条件为 truthy ,则问号后面的表达式A将会执行;表达式A后面跟着一个冒号(:),如果条件为 falsy ,则冒号后面的表达式B将会执行。本运算符经常作为 [if](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/if...else) 语句的简捷形式来使用。 语法:condition ? exprIfTrue : exprIfFalsecondition 计算结果用作条件的表达式。exprIfTrue 如果表达式 condition 的计算结果是 truthy(它和 true 相等或者可以转换成 true ),那么表达式 exprIfTrue 将会被求值。exprIfFalse 如果表达式 condition 的计算结果是 falsy(它可以转换成 false ),那么表达式 exprIfFalse 将会被执行。 示例:let getUser = function(name){ return name === 'leo' ? 'hello leo!' : 'unknow user'; } // 可以简写如下: let getUser = name => name === 'leo' ? 'hello leo!' : 'unknow user'; getUser('leo'); // "hello leo!" getUser('pingan'); // "unknow user"十一、逻辑运算符详细可以阅读《MDN 逻辑运算符》 。1. 运算符介绍逻辑运算符如下表所示 (其中_expr_可能是任何一种类型, 不一定是布尔值):运算符语法说明逻辑与,AND(&&)_expr1_ && _expr2_若 expr**1** 可转换为 true,则返回 expr**2**;否则,返回 expr**1**。逻辑或,OR(||)_expr1_ || _expr2_若 expr**1** 可转换为 true,则返回 expr**1**;否则,返回 expr**2**。逻辑非,NOT(!)!_expr_若 expr 可转换为 true,则返回 false;否则,返回 true。如果一个值可以被转换为 true,那么这个值就是所谓的 truthy,如果可以被转换为 false,那么这个值就是所谓的 falsy。 会被转换为 false 的表达式有:null;NaN;0;空字符串("" or '' or ````);undefined。 尽管 && 和 || 运算符能够使用非布尔值的操作数, 但它们依然可以被看作是布尔操作符,因为它们的返回值总是能够被转换为布尔值。如果要显式地将它们的返回值(或者表达式)转换为布尔值,请使用双重非运算符(即!!)或者Boolean构造函数。 JavaScript 里有三个逻辑运算符:||(或),&&(与),!(非)。2. 运算符示例逻辑与(&&) 所有条件都为 true 才返回 true,否则为 false。a1 = true && true // t && t 返回 true a2 = true && false // t && f 返回 false a3 = false && true // f && t 返回 false a4 = false && (3 == 4) // f && f 返回 false a5 = "Cat" && "Dog" // t && t 返回 "Dog" a6 = false && "Cat" // f && t 返回 false a7 = "Cat" && false // t && f 返回 false a8 = '' && false // f && f 返回 "" a9 = false && '' // f && f 返回 false逻辑或( || ) 所有条件有一个为 true 则返回 true,否则为 false。o1 = true || true // t || t 返回 true o2 = false || true // f || t 返回 true o3 = true || false // t || f 返回 true o4 = false || (3 == 4) // f || f 返回 false o5 = "Cat" || "Dog" // t || t 返回 "Cat" o6 = false || "Cat" // f || t 返回 "Cat" o7 = "Cat" || false // t || f 返回 "Cat" o8 = '' || false // f || f 返回 false o9 = false || '' // f || f 返回 ""逻辑非( ! )n1 = !true // !t 返回 false n2 = !false // !f 返回 true n3 = !'' // !f 返回 true n4 = !'Cat' // !t 返回 false双重非运( !! )n1 = !!true // !!truthy 返回 true n2 = !!{} // !!truthy 返回 true: 任何 对象都是 truthy 的… n3 = !!(new Boolean(false)) // …甚至 .valueOf() 返回 false 的布尔值对象也是! n4 = !!false // !!falsy 返回 false n5 = !!"" // !!falsy 返回 false n6 = !!Boolean(false) // !!falsy 返回 false3. 布尔值转换规则将 && 转换为 ||condi1 && confi2 // 转换为 !(!condi1 || !condi2)将 || 转换为 &&condi1 || condi2 // 转换为 !(!condi1 && !condi2)4. 短路取值由于逻辑表达式的运算顺序是从左到右,也可以用以下规则进行"短路"计算:(some falsy expression) && (_expr)_ 短路计算的结果为假。(some truthy expression) || _(expr)_ 短路计算的结果为真。 短路意味着上述表达式中的expr部分不会被执行,因此expr的任何副作用都不会生效(举个例子,如果expr是一次函数调用,这次调用就不会发生)。造成这种现象的原因是,整个表达式的值在第一个操作数被计算后已经确定了。看一个例子:function A(){ console.log('called A'); return false; } function B(){ console.log('called B'); return true; } console.log( A() && B() ); // logs "called A" due to the function call, // then logs false (which is the resulting value of the operator) console.log( B() || A() ); // logs "called B" due to the function call, // then logs true (which is the resulting value of the operator)5. 注意与运算 && 的优先级比或运算 || 要高。 所以代码 a && b || c && d 完全跟 && 表达式加了括号一样:(a && b) || (c && d)。十二、循环:while 和 for1. while 循环详细可以阅读《MDN while》 。 while 语句可以在某个条件表达式为真的前提下,循环执行指定的一段代码,直到那个表达式不为真时结束循环。 如:var n = 0; var x = 0; while (n < 3) { n++; x += n; }当循环体为单行时,可以不写大括号:let i = 3; while(i) console.log(i --);2. do...while 循环详细可以阅读《MDN do...while》 。 do...while 语句创建一个执行指定语句的循环,直到condition值为 false。在执行statement 后检测condition,所以指定的statement至少执行一次。 如:var result = ''; var i = 0; do { i += 1; result += i + ' '; } while (i < 5);3. for 循环详细可以阅读《MDN for》 。 for 语句用于创建一个循环,它包含了三个可选的表达式,这三个表达式被包围在圆括号之中,使用分号分隔,后跟一个用于在循环中执行的语句(通常是一个块语句)。 语法如:for (begin; condition; step) { // ……循环体…… }示例:for (let i = 0; i < 3; i++) { console.log(i); }描述:begini = 0进入循环时执行一次。conditioni < 3在每次循环迭代之前检查,如果为 false,停止循环。body(循环体)alert(i)条件为真时,重复运行。stepi++在每次循环体迭代后执行。4. 可选的 for 表达式for 语句头部圆括号中的所有三个表达式都是可选的。不指定表达式中初始化块var i = 0; for (; i < 3; i++) { console.log(i); }不指定表达式中条件块,这就必须要求在循环体中结束循环,否则会出现死循环for (var i = 0;; i++) { console.log(i); if (i > 3) break; }不指定所有表达式,也需要在循环体中指定结束循环的条件var i = 0; for (;;) { if (i > 3) break; console.log(i); i++; }5. break 语句详细可以阅读《MDN break》 。 break 语句中止当前循环,switch语句或label 语句,并把程序控制流转到紧接着被中止语句后面的语句。 在 while 语句中:function testBreak(x) { var i = 0; while (i < 6) { if (i == 3) { break; } i += 1; } return i * x; }另外,也可以为代码块做标记,并在 break 中指定要跳过的代码块语句的 label:outer_block:{ inner_block:{ console.log ('1'); break outer_block; // breaks out of both inner_block and outer_block console.log (':-('); // skipped } console.log ('2'); // skipped }需要注意的是:break 语句需要内嵌在它所应用的标签或代码块中,否则报错:block_1:{ console.log ('1'); break block_2; // SyntaxError: label not found } block_2:{ console.log ('2'); }6. continue 语句continue 声明终止当前循环或标记循环的当前迭代中的语句执行,并在下一次迭代时继续执行循环。 与 break 语句的区别在于, continue 并不会终止循环的迭代,而是:在 while 循环中,控制流跳转回条件判断;在 for 循环中,控制流跳转到更新语句。 注意:continue 也必须在对应循环内部,否则报错。i = 0; n = 0; while (i < 5) { i++; if (i === 3) { continue; } n += i; }带 label:var i = 0, j = 8; checkiandj: while (i < 4) { console.log("i: " + i); i += 1; checkj: while (j > 4) { console.log("j: "+ j); j -= 1; if ((j % 2) == 0) continue checkj; console.log(j + " is odd."); } console.log("i = " + i); console.log("j = " + j); }7. 注意禁止 break/continue 在 ‘?’ 的右边:(i > 5) ? console.log(i) : continue; // continue 不允许在这个位置这样会提示语法错误。 请注意非表达式的语法结构不能与三元运算符 ? 一起使用。特别是 break/continue 这样的指令是不允许这样使用的。8. 总结三种循环:while —— 每次迭代之前都要检查条件。do..while —— 每次迭代后都要检查条件。for (;;) —— 每次迭代之前都要检查条件,可以使用其他设置。 通常使用 while(true) 来构造“无限”循环。这样的循环和其他循环一样,都可以通过 break 指令来终止。 如果我们不想在当前迭代中做任何事,并且想要转移至下一次迭代,那么可以使用 continue 指令。 break/continue 支持循环前的标签。标签是 break/continue 跳出嵌套循环以转到外部的唯一方法。十三、"switch" 语句switch 语句用来将表达式的值与 case 语句匹配,并执行与情况对应的语句。 switch 语句可以替代多个 if 判断,为多个分支选择的情况提供一个更具描述性的方式。1. 语法switch 语句至少包含一个 case 代码块和一个可选的 default 代码块:switch(expression) { case 'value1': // do something ... [break] default: // ... [break] }当 expression 表达式的值与 value1 匹配时,则执行其中代码块。 如果没有 case 子句匹配,则会选择 default 子句执行,若连 default 子句都没有,则直接执行到 switch 结束。2. 使用 case 分组所谓 case 分组,就是与多个 case 分支共享同一段代码,如下面例子中 case 1 和 case 2:let a = 2; switch (a) { case 1: // (*) 下面这两个 case 被分在一组 case 2: console.log('case is 1 or 2!'); break; case 3: console.log('case is 3!'); break; default: console.log('The result is default.'); } // 'case is 1 or 2!'3. 注意点expression 表达式的值与 case 值的比较是严格相等:function f(n){ let a ; switch(n){ case 1: a = 'number'; break; case '1': a = 'string'; break; default: a = 'default'; break; } console.log(a) } f(1); // number f('1'); // string**如果没有 break,程序将不经过任何检查就会继续执行下一个 ****case** :let a = 2 + 2; switch (a) { case 3: console.log( 'Too small' ); case 4: console.log( 'Exactly!' ); case 5: console.log( 'Too big' ); default: console.log( "I don't know such values" ); } // Exactly! // Too big // I don't know such values**default** **放在 ****case** 之上不影响匹配:function f(n){ switch (n) { case 2: console.log(2); break; default: console.log('default') break; case 1: console.log('1'); break; } } f(1); // 1 f(2); // 2 f(3); // defaultswitch 语句中存在 let / const重复声明问题:// 以下定义会报错 function f(n){ switch(n){ case 1: let msg = 'hello'; console.log(1); break; case 2: let msg = 'leo'; break; default: console.log('default'); break; } } // Error: Uncaught SyntaxError: Identifier 'msg' has already been declared这是由于两个 let 处于同一个块级作用域,所以它们被认为是同一变量名的重复声明。 解决方式,只需要将 case 语句包装在括号内即可解决:function f(n){ switch(n){ case 1:{ // added brackets let msg = 'hello'; console.log(msg); break; } case 2: { let msg = 'leo'; console.log(msg); break; } default: console.log('default'); break; } }十四、函数函数可以让一段代码被多次调用,避免重复代码。 如之前学习到的一些内置函数: alert(msg) / prompt(msg, default) / confirm(quesyion) 等。1. 函数定义定义函数有两种方式:函数声明和函数表达式。1.1 函数声明如定义一个简单 getUser 函数:function getUser(name){ return 'hello ' + name; } getUser('leo"); // 函数调用通过函数声明来定义函数时,需要由以下几部分组成:函数名称 - getUser ;函数参数列表 - name ;函数的 JS 执行语句 - return 'hello ' + name 。1.2 函数表达式类似声明变量,还是以 getUser 为例:let getUser = function(name){ return 'hello ' + name; }另外,函数表达式也可以提供函数名,并用于函数内部指代函数本身:let fun = function f(n){ return n < 3 ? 1 : n * f(n - 1); } fun(3); // 3 fun(5); // 602. 函数调用当定义一个函数后,它并不会自动执行,而是需要使用函数名称进行调用,如上面例子:fun(3); // 3只要注意: 使用 函数表达式 定义函数时,调用函数的方法必须写在定义之后,否则报错:console.log(fun()); // Uncaught ReferenceError: fun is not defined let fun = function(){return 1};而使用 函数声明 则不会出现该问题:console.log(fun()); // 1 function fun(){return 1};原因就是:函数提升仅适用于函数声明,而不适用于函数表达式。3. 函数中的变量在函数中,可以使用局部变量和外部变量。3.1 局部变量函数中声明的变量只能在该函数内可见。let fun = function(){ let name = 'leo'; } fun(); console.log(name); // Uncaught ReferenceError: name is not defined3.2 全局变量函数内可以使用外部变量,并且可以修改外部变量的值。let name = 'leo'; let fun = function(){ let text = 'Hello, ' + name; console.log(text); } fun(); // Hello, leo当函数内也有与外部变量名称相同的变量,会忽略外部变量:let name = 'leo'; let fun = function(){ let name = 'pingan8787'; let text = 'Hello, ' + name; console.log(text); } fun(); // Hello, pingan87874. 函数参数从ECMAScript 6开始,有两个新的类型的参数:默认参数,剩余参数。4.1 默认参数若函数没有传入参数,则参数默认值为undefined,通常设置参数默认值是这样做的:// ES6 之前,没有设置默认值 function f(a, b){ b = b ? b : 1; return a * b; } f(2,3); // 6 f(2); // 2 // ES6,设置默认值 function f(a, b = 1){ return a * b; } f(2,3); // 6 f(2); // 24.2 剩余参数可以将参数中不确定数量的参数表示成数组,如下:function f (a, ...b){ console.log(a, b); } f(1,2,3,4); // a => 1 b => [2, 3, 4]既然讲到参数,那就不能少了 arguments 对象。4.3 arguments 对象函数的实际参数会被保存在一个类似数组的arguments对象中。在函数内,我们可以使用 arguments 对象获取函数的所有参数:let fun = function(){ console.log(arguments); console.log(arguments.length); } fun('leo'); // Arguments ["leo", callee: ƒ, Symbol(Symbol.iterator): ƒ] // 1以一个实际示例介绍,实现将任意数量参数连接成一个字符串,并输出的函数:let argumentConcat = function(separator){ let result = '', i; for(i = 1; i < arguments.length; i ++){ result += arguments[i] + separator; } return result; } argumentConcat(',', 'leo', 'pingan'); //"leo,pingan,"5. 函数返回值在函数任意位置,指定 return 指令来停止函数的执行,并返回函数指定的返回值。let sum = function(a, b){ return a + b; }; let res = sum(1, 2); console.log(res); // 3默认空值的 return 或没有 return 的函数返回值为 undefined 。十五、函数表达式函数表达式是一种函数定义方式,在前面章节中已经介绍到了,这个部分将着重介绍 函数表达式 和 函数声明 的区别:1. 语法差异// 函数表达式 let fun = function(){}; // 函数声明 function fun(){}2. 创建时机差异函数表达式会在代码执行到达时被创建,并且仅从那一刻可用。 而函数声明被定义之前,它就可以被调用。// 函数表达式 fun(); // Uncaught ReferenceError: fun is not defined let fun = function(){console.log('leo')}; // 函数声明 fun(); // "leo" function fun(){console.log('leo')};3. 使用建议建议优先考虑函数声明语法,它能够为组织代码提供更多灵活性,因为我们可以在声明函数前调用该函数。十六、箭头函数本章节简单介绍箭头函数基础知识,后面章节会完整介绍。 函数箭头表达式是ES6新增的函数表达式的语法,也叫胖箭头函数,变化:更简洁的函数和this。1. 代码更简洁// 有1个参数 let f = v => v; // 等同于 let f = function (v){return v}; // 有多个参数 let f = (v, i) => {return v + i}; // 等同于 let f = function (v, i){return v + i}; // 没参数 let f = () => 1; // 等同于 let f = function (){return 1}; let arr = [1,2,3,4]; arr.map(ele => ele + 1); // [2, 3, 4, 5]2. 注意点箭头函数不存在this;箭头函数不能当做构造函数,即不能用new实例化;箭头函数不存在arguments对象,即不能使用,可以使用rest参数代替;箭头函数不能使用yield命令,即不能用作Generator函数。 一个简单的例子:function Person(){ this.age = 0; setInterval(() => { this.age++; }, 1000); } var p = new Person(); // 定时器一直在执行 p的值一直变化
最近原创文章😊:《了不起的 Webpack HMR 学习指南(含源码分析)》《了不起的 Webpack 构建流程学习指南》《你不知道的 WeakMap》番外篇《你不知道的 Blob》番外篇《了不起的 tsconfig.json 指南》《200行JS代码,带你实现代码编译器》前言最近与部门老大一起面试了许多前端求职者,其中想换个学习氛围较好的人占多数,但良好的学习氛围也是需要一点点营造出来的🌺。为此我们组建了我们团队内部的“现代 JavaScript 突击队”,第一期学习内容为《现代 JavaScript 教程》系列,帮助小组成员系统地进行学习巩固,并让大家养成系统性学习和输出学习总结的学习方式。本文作为我输出的第一部分学习总结,希望作为一份自测清单,帮助大家巩固知识,温故知新。这里也下面分享我们学习小组的“押金制度”和“押金记录表”🍀接下来开始分享自测清单的内容。一、Hello World!1. 脚本引入方式JavaScript 脚本引入方式有两种:<script> 标签插入脚本;<script> 标签 src 设置脚本地址。2. script 标签属性<script> 标签有以下常用属性:2.1 srcsrc :指定外部脚本的URI, 如果设置了 src 特性,script 标签内容将会被忽略;<script src="example-url.js"></script>2.2 typetype :指定引用脚本的语言,属性值为 MIME 类型,包括text/javascript, text/ecmascript, application/javascript, 和application/ecmascript。如果没有定义这个属性,脚本会被视作JavaScript。ES6 新增了属性值 module ,代码会被当做 JavaScript 模块。<script type="text/javascript"></script>2.3 asyncasync 规定一旦脚本可用,则会异步执行。 注意:async 属性仅适用于外部脚本(只有在使用 src 属性时)。 有多种执行外部脚本的方法: 如果 async="async" :脚本相对于页面的其余部分异步地执行(当页面继续进行解析时,脚本将被执行); 如果不使用 async 且 defer="defer" :脚本将在页面完成解析时执行; 如果既不使用 async 也不使用 defer :在浏览器继续解析页面之前,立即读取并执行脚本;<script async="async"></script>2.4 deferdefer 属性规定是否对脚本执行进行延迟,直到页面加载为止。如果您的脚本不会改变文档的内容,可将 defer 属性加入到 <script> 标签中,以便加快处理文档的速度。因为浏览器知道它将能够安全地读取文档的剩余部分而不用执行脚本,它将推迟对脚本的解释,直到文档已经显示给用户为止。<script defer="defer"></script>详细介绍可以阅读《MDN <script>章节 》。二、代码结构1. 语句语句是执行行为(action)的语法结构和命令。如: alert('Hello, world!') 这样可以用来显示消息的语句。2. 分号存在分行符时,多数情况下可以省略分号。但不全是,比如:alert(3 + 1 + 2);建议新人最好不要省略分号。3. 注释单行注释以两个正斜杠字符 // 开始。// 注释文本 console.log("leo");多行注释以一个正斜杠和星号开始 “/*” 并以一个星号和正斜杆结束 “*/”。/* 这是多行注释。 第二行注释。 */ console.log("leo");三、现代模式,"use strict"1. 作用JavaScript 的严格模式是使用受限制的 JavaScript 的一种方式,从而隐式地退出“草率模式”。"use strict" 指令将浏览器引擎转换为“现代”模式,改变一些内建特性的行为。2. 使用通过在脚本文件/函数开头添加 "use strict"; 声明,即可启用严格模式。 全局开启严格模式:// index.js "use strict"; const v = "Hi! I'm a strict mode script!";函数内开启严格模式:// index.js function strict() { 'use strict'; function nested() { return "And so am I!"; } return "Hi! I'm a strict mode function! " + nested(); }3. 注意点"use strict" 需要定义在脚本最顶部(函数内除外),否则严格模式可能无法启用。一旦进入了严格模式,就无法关闭严格模式。4. 体验启用 "use strict" 后,为未定义元素赋值将抛出异常:"use strict"; leo = 17; // Uncaught ReferenceError: leo is not defined启用 "use strict" 后,试图删除不可删除的属性时会抛出异常:"use strict"; delete Object.prototype; // Uncaught TypeError: Cannot delete property 'prototype' of function Object() { [native code] }详细介绍可以阅读《MDN 严格模式章节 》。四、变量1. 介绍变量是数据的“命名存储”。2. 使用目前定义变量可以使用三种关键字:var / let / const。三者区别可以阅读《let 和 const 命令》 。let name = "leo"; let name = "leo", age, addr; let name = "leo", age = 27, addr = "fujian";3. 命名建议变量命名有 2 个限制:变量名称必须仅包含字母,数字,符号 $ 和 _。首字符必须非数字。 变量命名还有一些建议:常量一般用全大写,如 const PI = 3.141592 ;使用易读的命名,比如 userName 或者 shoppingCart。4. 注意点JavaScript 变量名称区分大小写,如变量 leo 与 Leo 是不同的;JavaScript 变量名称允许非英文字母,但不推荐,如 let 平安 = "leo" ;避免使用 a、b、c 这种缩写。五、数据类型JavaScript 是一种弱类型或者说动态语言。这意味着你不用提前声明变量的类型,在程序运行过程中,类型会被自动确定。这也意味着你可以使用同一个变量保存不同类型的数据:var foo = 42; // foo is a Number now foo = "bar"; // foo is a String now foo = true; // foo is a Boolean now详细介绍可以阅读《MDN JavaScript 数据类型和数据结构 》。1. 八大数据类型前七种为基本数据类型,也称为原始类型(值本身无法被改变),而 object 为复杂数据类型。 八大数据类型分别是:number 用于任何类型的数字:整数或浮点数,在 ±2 范围内的整数。bigint 用于任意长度的整数。string 用于字符串:一个字符串可以包含一个或多个字符,所以没有单独的单字符类型。boolean 用于 true 和 false。null 用于未知的值 —— 只有一个 null 值的独立类型。undefined 用于未定义的值 —— 只有一个 undefined 值的独立类型。symbol 用于唯一的标识符。object 用于更复杂的数据结构。 每个类型后面会详细介绍。2. 检测数据类型通过 typeof 运算符检查:两种形式:typeof x 或者 typeof(x)。以字符串的形式返回类型名称,例如 "string"。typeof null 会返回 "object" —— 这是 JavaScript 编程语言的一个错误,实际上它并不是一个 object。typeof "leo" // "string" typeof undefined // "undefined" typeof 0 // "number" typeof NaN // "number" typeof 10n // "bigint" typeof true // "boolean" typeof Symbol("id") // "symbol" typeof [1,2,3,4] // "object" typeof Math // "object" (1) Math 是一个提供数学运算的内建 object。 typeof null // "object" (2) JavaScript 语言的一个错误,null 不是一个 object。null 有自己的类型,它是一个特殊值。 typeof alert // "function" (3) alert 在 JavaScript 语言中是一个函数。六、类型转换JavaScript 变量可以转换为新变量或其他数据类型:通过使用 JavaScript 函数通过 JavaScript 自身自动转换1. 字符串转换通过全局方法 String() 将**其他类型数据(任何类型的数字,字母,布尔值,对象)**转换为 String 类型:String(123); // "123" // Number方法toString()/toExponential()/toFixed()/toPrecision() 也有同样效果。 String(false); // "false" // Boolean方法 toString() 也有同样效果。 String(new Date()); // "Sun Jun 07 2020 21:44:20 GMT+0800 (中国标准时间)" // Date方法 toString() 也有同样效果。 String(leo);2. 数值转换通过以下几种方式能将其他类型数据转换为 Number 类型:一元运算符 +const age = +"22"; // 22Number 方法const age = Number("22"); // 22 Number.parseFloat("22"); // 22 Number.parseInt("22"); // 22其他方式转 Number 类型// 布尔值 Number(false) // 返回 0 Number(true) // 返回 1 // 日期 const date = new Date(); Number(date); // 返回 1591537858154 date.getTime(); // 返回 1591537858154,效果一致。 // 自动转换 5 + null // 返回 5 null 转换为 0 "5" + null // 返回"5null" null 转换为 "null" "5" + 1 // 返回 "51" 1 转换为 "1" "5" - 1 // 返回 4 "5" 转换为 53. 布尔值转换转换规则如下:直观上为“空”的值(如 0、空字符串、null、undefined 和 NaN)将变为 false。其他值变成 true。Boolean(1); // true Boolean(0); // false Boolean("hello"); // true Boolean(""); // false Boolean("0"); // true Boolean(" "); // 空白, 也是 true (任何非空字符串是 true)4. 小结七、运算符1、运算符概念常见运算符如加法 + 、减法 - 、乘法 * 和除法 / ,举一个例子,来介绍一些概念:let sum = 1 + 2; let age = +18;其中:加法运算 1 + 2 中, 1 和 2 为 2 个运算元,左运算元 1 和右运算元 2 ,即运算元就是运算符作用的对象。1 + 2 运算式中包含 2 个运算元,因此也称该运算式中的加号 + 为 二元运算符。在 +18 中的加号 + 对应只有一个运算元,则它是 一元运算符 。2、+ 号运算符let msg = "hello " + "leo"; // "hello leo" let total = 10 + 20; // 30 let text1 = "1" + "2"; // "12" let text2 = "1" + 2; // "12" let text3 = 1 + "2"; // "12" let text4 = 1 + 2 + "3"; // "33" let num = +text1; // 12 转换为 Number 类型3、运算符优先级运算符的优先级决定了表达式中运算执行的先后顺序,优先级高的运算符最先被执行。 下面的表将所有运算符按照优先级的不同从高(20)到低(1)排列。优先级运算类型关联性运算符20圆括号n/a(不相关)( … )19成员访问从左到右… . …需计算的成员访问从左到右… [ … ]new (带参数列表)n/anew … ( … )函数调用从左到右… ( … )可选链(Optional chaining)从左到右?.18new (无参数列表)从右到左new …17后置递增(运算符在后)n/a… ++后置递减(运算符在后)… --16逻辑非从右到左! …按位非~ …一元加法+ …一元减法- …前置递增++ …前置递减-- …typeoftypeof …voidvoid …deletedelete …awaitawait …15幂从右到左… ** …14乘法从左到右… * …除法… / …取模… % …13加法从左到右… + …减法… - …12按位左移从左到右… << …按位右移… >> …无符号右移… >>> …11小于从左到右… < …小于等于… <= …大于… > …大于等于… >= …in… in …instanceof… instanceof …10等号从左到右… == …非等号… != …全等号… === …非全等号… !== …9按位与从左到右… & …8按位异或从左到右… ^ …7按位或从左到右… | …6逻辑与从左到右… && …5逻辑或从左到右… || …4条件运算符从右到左… ? … : …3赋值从右到左… = …… += …… -= …… *= …… /= …… %= …… <<= …… >>= …… >>>= …… &= …… ^= …… |= …2yield从右到左yield …yield*yield* …1展开运算符n/a... …0逗号从左到右… , …3 > 2 && 2 > 1 // return true 3 > 2 > 1 // 返回 false,因为 3 > 2 是 true,并且 true > 1 is false // 加括号可以更清楚:(3 > 2) > 1八、值的比较1. 常见比较在 JS 中的值的比较与数学很类型:大于/小于/大于等于/小于等于: a>b / a<b / a>=b / a<=b ;判断相等:// 使用 ==,非严格等于,不关心值类型 // == 运算符会对比较的操作数做隐式类型转换,再比较 '1' == 1; // true // 使用 ===,严格相等,关心值类型 // 将数字值 -0 和 +0 视为相等,并认为 Number.NaN 不等于 NaN。 '1' === 1; // false(图片来自:《MDN JavaScript 中的相等性判断》)判断不相等: 和判断相等一样,也有两种: != / !== 。2. 相等性判断(Object.is())另外 ES6 新增 Object.is 方法判断两个值是否相同,语法如下:Object.is(value1, value2);以下任意项成立则两个值相同:两个值都是 undefined两个值都是 null两个值都是 true 或者都是 false两个值是由相同个数的字符按照相同的顺序组成的字符串两个值指向同一个对象两个值都是数字并且都是正零 +0都是负零 -0都是 NaN都是除零和 NaN 外的其它同一个数字 使用示例:Object.is('foo', 'foo'); // true Object.is(window, window); // true Object.is('foo', 'bar'); // false Object.is([], []); // false var foo = { a: 1 }; var bar = { a: 1 }; Object.is(foo, foo); // true Object.is(foo, bar); // false Object.is(null, null); // true // 特例 Object.is(0, -0); // false Object.is(0, +0); // true Object.is(-0, -0); // true Object.is(NaN, 0/0); // true兼容性 Polyfill 处理:if (!Object.is) { Object.is = function(x, y) { // SameValue algorithm if (x === y) { // Steps 1-5, 7-10 // Steps 6.b-6.e: +0 != -0 return x !== 0 || 1 / x === 1 / y; } else { // Step 6.a: NaN == NaN return x !== x && y !== y; } }; }3. null 与 undefined 比较对于相等性判断比较简单:null == undefined; // true null === undefined; // false对于其他比较,它们会先转换位数字: null 转换为 0 , undefied 转换为 NaN 。null > 0; // false 1 null >= 0; // true 2 null == 0; // false 3 null < 1; // true 4需要注意: null == 0; // false 这里是因为:undefined 和 null 在相等性检查 == 中不会进行任何的类型转换,它们有自己独立的比较规则,所以除了它们之间互等外,不会等于任何其他的值。undefined > 0; // false 1 undefined > 1; // false 2 undefined == 0; // false 3第 1、2 行都返回 false 是因为 undefined 在比较中被转换为了 NaN,而 NaN 是一个特殊的数值型值,它与任何值进行比较都会返回 false。 第 3 行返回 false 是因为这是一个相等性检查,而 undefined 只与 null 相等,不会与其他值相等。
四、HMR 完整原理和源码分析通过上一节内容,我们大概知道 HMR 简单工作流程,那么或许你现在可能还有很多疑惑:文件更新是什么通知 HMR Plugin?HMR Plugin 怎么发送更新到 HMR Runtime?等等问题。那么接下来我们开始详细结合源码分析整个 HMR 模块热更新流程,首先还是先看流程图,可以先不了解图中方法名称(红色字体黄色背景色部分):Webpack HMR.png上图展示了从我们修改代码,到模块热更新完成的一个 HMR 完整工作流程,图中已用红色阿拉伯数字符号将流程标识出来。要了解上面工作原理,我们先理解图中这几个名称概念:Webpack-dev-server :一个服务器插件,相当于 express 服务器,启动一个 Web 服务,只适用于开发环境;Webpack-dev-middleware :一个 Webpack-dev-server 的中间件,作用简单总结为:通过watch mode,监听资源的变更,然后自动打包。Webpack-hot-middleware :结合 Webpack-dev-middleware 使用的中间件,它可以实现浏览器的无刷新更新,也就是 HMR;下面一起学习 HMR 整个工作原理吧:1.监控代码变化,重新编译打包首先根据 devServer 配置,使用 npm start 将启动 Webpack-dev-server 启动本地服务器并进入 Webpack 的 watch 模式,然后初始化 Webpack-dev-middleware ,在 Webpack-dev-middleware 中通过调用 startWatch() 方法对文件系统进行 watch:// webpack-dev-server\bin\webpack-dev-server.js // 1.启动本地服务器 Line 386 server = new Server(compiler, options); // webpack-dev-server\lib\Server.js // 2.初始化 Webpack-dev-middleware Line 109 this.middleware = webpackDevMiddleware(compiler, Object.assign({}, options, wdmOptions)); // webpack-dev-middleware\lib\Shared.js // 3.开始 watch 文件系统 Line 171 startWatch: function() { //... // start watching if(!options.lazy) { var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback); context.watching = watching; } //... } share.startWatch(); // ...当 startWatch() 方法执行后,便进入 watch 模式,若发现文件中代码发生修改,则根据配置文件对模块重新编译打包。2.保存编译结果Webpack 与 Webpack-dev-middleware 交互,Webpack-dev-middleware 调用 Webpack 的 API 对代码变化进行监控,并通知 Webpack 将重新编译的代码通过 JavaScript 对象保存在内存中。我们会发现,在 output.path 指定的 dist 目录并没有保存编译结果的文件,这是为什么?其实, Webpack 将编译结果保存在内存中,因为访问内存中的代码比访问文件系统中的文件快,这样可以减少代码写入文件的开销。Webpack 能将代码保存到内存中,需要归功于 Webpack-dev-middleware 的 memory-fs 依赖库,它将原本 outputFileSystem 替换成了 MemoryFileSystem 的实例,便实现代码输出到内存中。其中部分源码如下:// webpack-dev-middleware\lib\Shared.js Line 108 // store our files in memory var fs; var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem; if(isMemoryFs) { fs = compiler.outputFileSystem; } else { fs = compiler.outputFileSystem = new MemoryFileSystem(); } context.fs = fs;上述代码先判断 fileSystem 是否是 MemoryFileSystem 的实例,若不是,则用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 JavaScript 对象保存在内存中,当浏览器请求 bundle.js 文件时,devServer 就直接去内存中找到上面保存的 JavaScript 对象并返回给浏览器端。3.监控文件变化,刷新浏览器Webpack-dev-server 开始监控文件变化,与第 1 步不同的是,这里并不是监控代码变化重新编译打包。当我们在配置文件中配置了 devServer.watchContentBase 为 true ,Webpack-dev-server 会监听配置文件夹中静态文件的变化,发生变化时,通知浏览器端对应用进行浏览器刷新,这与 HMR 不一样。// webpack-dev-server\lib\Server.js // 1. 读取参数 Line 385 if (options.watchContentBase) { defaultFeatures.push('watchContentBase'); } // 2. 定义 _watch 方法 Line 697 Server.prototype._watch = function (watchPath) { // ... const watcher = chokidar.watch(watchPath, options).on('change', () => { this.sockWrite(this.sockets, 'content-changed'); }); this.contentBaseWatchers.push(watcher); }; // 3. 执行 _watch() 监听文件变化 Line 339 watchContentBase: () => { if (/^(https?:)?\/\//.test(contentBase) || typeof contentBase === 'number') { throw new Error('Watching remote files is not supported.'); } else if (Array.isArray(contentBase)) { contentBase.forEach((item) => { this._watch(item); }); } else { this._watch(contentBase); } }4.建立 WS,同步编译阶段状态这一步都是 Webpack-dev-server 中处理,主要通过 sockjs(Webpack-dev-server 的依赖),在 Webpack-dev-server 的浏览器端(Client)和服务器端(Webpack-dev-middleware)之间建立 WebSocket 长连接。然后将 Webpack 编译打包的各个阶段状态信息同步到浏览器端。其中有两个重要步骤:发送状态Webpack-dev-server 通过 Webpack API 监听 compile 的 done 事件,当 compile 完成后,Webpack-dev-server 通过 _sendStats 方法将编译后新模块的 hash 值用 socket 发送给浏览器端。保存状态浏览器端将_sendStats 发送过来的 hash 保存下来,它将会用到后模块热更新。 // webpack-dev-server\lib\Server.js // 1. 定义 _sendStats 方法 Line 685 // send stats to a socket or multiple sockets Server.prototype._sendStats = function (sockets, stats, force) { //... this.sockWrite(sockets, 'hash', stats.hash); }; // 2. 监听 done 事件 Line 86 compiler.plugin('done', (stats) => { // 将最新打包文件的 hash 值(stats.hash)作为参数传入 _sendStats() this._sendStats(this.sockets, stats.toJson(clientStats)); this._stats = stats; }); // webpack-dev-server\client\index.js // 3. 保存 hash 值 Line 74 var onSocketMsg = { // ... hash: function hash(_hash) { currentHash = _hash; }, // ... } socket(socketUrl, onSocketMsg);5.浏览器端发布消息当 hash 消息发送完成后,socket 还会发送一条 ok 的消息告知 Webpack-dev-server,由于客户端(Client)并不请求热更新代码,也不执行热更新模块操作,因此通过 emit 一个 "webpackHotUpdate" 消息,将工作转交回 Webpack。// webpack-dev-server\client\index.js // 1. 处理 ok 消息 Line 135 var onSocketMsg = { // ... ok: function ok() { sendMsg('Ok'); if (useWarningOverlay || useErrorOverlay) overlay.clear(); if (initial) return initial = false; // eslint-disable-line no-return-assign reloadApp(); }, // ... } // 2. 处理刷新 APP Line 218 function reloadApp() { // ... if (_hot) { // 动态加载 emitter var hotEmitter = require('webpack/hot/emitter'); hotEmitter.emit('webpackHotUpdate', currentHash); if (typeof self !== 'undefined' && self.window) { // broadcast update to window self.postMessage('webpackHotUpdate' + currentHash, '*'); } } // ... }6.传递 hash 到 HMRWebpack/hot/dev-server 监听浏览器端 webpackHotUpdate 消息,将新模块 hash 值传到客户端 HMR 核心中枢的 HotModuleReplacement.runtime ,并调用 check 方法检测更新,判断是浏览器刷新还是模块热更新。 如果是浏览器刷新的话,则没有后续步骤咯~~// webpack\hot\dev-server.js // 1.监听 webpackHotUpdate Line 42 var hotEmitter = require("./emitter"); hotEmitter.on("webpackHotUpdate", function(currentHash) { lastHash = currentHash; if(!upToDate() && module.hot.status() === "idle") { log("info", "[HMR] Checking for updates on the server..."); check(); } }); var check = function check() { module.hot.check(true).then(function(updatedModules) { if(!updatedModules) { // ... window.location.reload();// 浏览器刷新 return; } if(!upToDate()) { check(); } }).catch(function(err) { /*...*/}); }; // webpack\lib\HotModuleReplacement.runtime.js // 3.调用 HotModuleReplacement.runtime 定义的 check 方法 Line 167 function hotCheck(apply) { if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status"); hotApplyOnUpdate = apply; hotSetStatus("check"); return hotDownloadManifest(hotRequestTimeout).then(function(update) { //... }); }7.检测是否存在更新当 HotModuleReplacement.runtime 调用 check 方法时,会调用 JsonpMainTemplate.runtime 中的 hotDownloadUpdateChunk (获取最新模块代码)和 hotDownloadManifest (获取是否有更新文件)两个方法,这两个方法的源码,在下一步展开。// webpack\lib\HotModuleReplacement.runtime.js // 1.调用 HotModuleReplacement.runtime 定义 hotDownloadUpdateChunk 方法 Line 171 function hotCheck(apply) { if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status"); hotApplyOnUpdate = apply; hotSetStatus("check"); return hotDownloadManifest(hotRequestTimeout).then(function(update) { //... { // hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk hotEnsureUpdateChunk(chunkId); } }); }其中 hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk :// webpack\lib\HotModuleReplacement.runtime.js Line 215 function hotEnsureUpdateChunk(chunkId) { if(!hotAvailableFilesMap[chunkId]) { hotWaitingFilesMap[chunkId] = true; } else { hotRequestedFilesMap[chunkId] = true; hotWaitingFiles++; hotDownloadUpdateChunk(chunkId); } }8.请求更新最新文件列表在调用 check 方法时,会先调用 JsonpMainTemplate.runtime 中的 hotDownloadManifest 方法, 通过向服务端发起 AJAX 请求获取是否有更新文件,如果有的话将 mainfest 返回给浏览器端。 这边涉及一些原生 XMLHttpRequest,就不全部贴出了~// webpack\lib\JsonpMainTemplate.runtime.js // hotDownloadManifest 定义 Line 22 function hotDownloadManifest(requestTimeout) { return new Promise(function(resolve, reject) { try { var request = new XMLHttpRequest(); var requestPath = $require$.p + $hotMainFilename$; request.open("GET", requestPath, true); request.timeout = requestTimeout; request.send(null); } catch(err) { return reject(err); } request.onreadystatechange = function() { // ... }; }); }9.请求更新最新模块代码在 hotDownloadManifest 方法中,还会执行 hotDownloadUpdateChunk 方法,通过 JSONP 请求最新的模块代码,并将代码返回给 HMR runtime 。 然后 HMR runtime 会将新代码进一步处理,判断是浏览器刷新还是模块热更新。// webpack\lib\JsonpMainTemplate.runtime.js // hotDownloadManifest 定义 Line 12 function hotDownloadUpdateChunk(chunkId) { // 创建 script 标签,发起 JSONP 请求 var head = document.getElementsByTagName("head")[0]; var script = document.createElement("script"); script.type = "text/javascript"; script.charset = "utf-8"; script.src = $require$.p + $hotChunkFilename$; $crossOriginLoading$; head.appendChild(script); }10.更新模块和依赖引用这一步是整个模块热更新(HMR)的核心步骤,通过 HMR runtime 的 hotApply 方法,移除过期模块和代码,并添加新的模块和代码实现热更新。从 hotApply 方法可以看出,模块热替换主要分三个阶段:找出过期模块 outdatedModules 和过期依赖 outdatedDependencies ;// webpack\lib\HotModuleReplacement.runtime.js // 找出 outdatedModules 和 outdatedDependencies Line 342 function hotApply() { // ... var outdatedDependencies = {}; var outdatedModules = []; function getAffectedStuff(updateModuleId) { var outdatedModules = [updateModuleId]; var outdatedDependencies = {}; // ... return { type: "accepted", moduleId: updateModuleId, outdatedModules: outdatedModules, outdatedDependencies: outdatedDependencies }; }; function addAllToSet(a, b) { for (var i = 0; i < b.length; i++) { var item = b[i]; if (a.indexOf(item) < 0) a.push(item); } } for(var id in hotUpdate) { if(Object.prototype.hasOwnProperty.call(hotUpdate, id)) { // ... 省略多余代码 if(hotUpdate[id]) { result = getAffectedStuff(moduleId); } if(doApply) { for(moduleId in result.outdatedDependencies) { // 添加到 outdatedDependencies addAllToSet(outdatedDependencies[moduleId], result.outdatedDependencies[moduleId]); } } if(doDispose) { // 添加到 outdatedModules addAllToSet(outdatedModules, [result.moduleId]); appliedUpdate[moduleId] = warnUnexpectedRequire; } } } }从缓存中删除过期模块、依赖和所有子元素的引用;// webpack\lib\HotModuleReplacement.runtime.js // 从缓存中删除过期模块、依赖和所有子元素的引用 Line 442 function hotApply() { // ... var idx; var queue = outdatedModules.slice(); while(queue.length > 0) { moduleId = queue.pop(); module = installedModules[moduleId]; // ... // 移除缓存中的模块 delete installedModules[moduleId]; // 移除过期依赖中不需要使用的处理方法 delete outdatedDependencies[moduleId]; // 移除所有子元素的引用 for(j = 0; j < module.children.length; j++) { var child = installedModules[module.children[j]]; if(!child) continue; idx = child.parents.indexOf(moduleId); if(idx >= 0) { child.parents.splice(idx, 1); } } } // 从模块子组件中删除过时的依赖项 var dependency; var moduleOutdatedDependencies; for(moduleId in outdatedDependencies) { if(Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)) { module = installedModules[moduleId]; if(module) { moduleOutdatedDependencies = outdatedDependencies[moduleId]; for(j = 0; j < moduleOutdatedDependencies.length; j++) { dependency = moduleOutdatedDependencies[j]; idx = module.children.indexOf(dependency); if(idx >= 0) module.children.splice(idx, 1); } } } } }将新模块代码添加到 modules 中,当下次调用 __webpack_require__ (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。// webpack\lib\HotModuleReplacement.runtime.js // 将新模块代码添加到 modules 中 Line 501 function hotApply() { // ... for(moduleId in appliedUpdate) { if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { modules[moduleId] = appliedUpdate[moduleId]; } } }hotApply 方法执行之后,新代码已经替换旧代码,但是我们业务代码并不知道这些变化,因此需要通过 accept事件通知应用层使用新的模块进行“局部刷新”,我们在业务中是这么使用: if (module.hot) { module.hot.accept('./library.js', function() { // 使用更新过的 library 模块执行某些操作... }) }11.热更新错误处理在热更新过程中,hotApply 过程中可能出现 abort 或者 fail 错误,则热更新退回到刷新浏览器(Browser Reload),整个模块热更新完成。// webpack\hot\dev-server.js Line 13 module.hot.check(true).then(function (updatedModules) { if (!updatedModules) { return window.location.reload(); } // ... }).catch(function (err) { var status = module.hot.status(); if (["abort", "fail"].indexOf(status) >= 0) { window.location.reload(); } });五、总结本文和大家主要分享 Webpack 的 HMR 使用和实现原理及源码分析,在源码分析中,通过一张“Webpack HMR 工作原理解析”图让大家对 HMR 整个工作流程有所了解,HMR 本身源码内容较多,许多细节之处本文没有完整写出,需要各位读者自己慢慢阅读和理解源码。参考文章1.官方文档《Hot Module Replacement》2.《Webpack HMR 原理解析》3.《webpack HMR》4.《配置 dev-server》
最近原创文章回顾😊:《1.2w字 | 初中级前端 JavaScript 自测清单 - 1》《了不起的 Webpack HMR 学习指南(含源码分析)》《了不起的 Webpack 构建流程学习指南》《你不知道的 WeakMap》番外篇《你不知道的 Blob》番外篇《了不起的 tsconfig.json 指南》《200行JS代码,带你实现代码编译器》学习章节:《Webpack HMR 原理解析》一、HMR 介绍Hot Module Replacement(以下简称:HMR 模块热替换)是 Webpack 提供的一个非常有用的功能,它允许在 JavaScript 运行时更新各种模块,而无需完全刷新。Hot Module Replacement (or HMR) is one of the most useful features offered by webpack. It allows all kinds of modules to be updated at runtime without the need for a full refresh. --《Hot Module Replacement》当我们修改代码并保存后,Webpack 将对代码重新打包,HMR 会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。HMR 主要通过以下几种方式,来显著加快开发速度:保留在完全重新加载页面时丢失的应用程序状态;只更新变更内容,以节省宝贵的开发时间;调整样式更加快速 - 几乎相当于在浏览器调试器中更改样式。需要注意:HMR 不适用于生产环境,这意味着它应当只在开发环境使用。二、HMR 使用方式在 Webpack 中启用 HMR 功能比较简单:1. 方式一:使用 devServer1.1 设置 devServer 选项只需要在 webpack.config.js 中添加 devServer 选项,并设置 hot 值为 true ,并使用HotModuleReplacementPlugin 和 NamedModulesPlugin (可选)两个 Plugins :// webpack.config.js const path = require('path') const webpack = require('webpack') module.exports = { entry: './index.js', output: { filename: 'bundle.js', path: path.join(__dirname, '/') }, + devServer: { + hot: true, // 启动模块热更新 HMR + open: true, // 开启自动打开浏览器页面 + }, plugins: [ + new webpack.NamedModulesPlugin(), + new webpack.HotModuleReplacementPlugin() ] }1.2 添加 scripts然后在 package.json 中为 scripts 命令即可:// package.json { // ... "scripts": { + "start": "webpack-dev-server" }, // ... }2. 方式二、使用命令行参数另一种是通过添加 --hot 参数来实现。添加 --hot 参数后,devServer 会告诉 Webpack 自动引入 HotModuleReplacementPlugin ,而不需要我们手动引入。另外常常也搭配 --open 来自动打开浏览器到页面。这里移除掉前面添加的两个 Plugins :// webpack.config.js const path = require('path') const webpack = require('webpack') module.exports = { // ... - plugins: [ - new webpack.NamedModulesPlugin(), - new webpack.HotModuleReplacementPlugin() - ] }然后修改 package.json 文件中的 scripts 配置:// package.json { // ... "scripts": { - "start": "webpack-dev-server" + "start": "webpack-dev-server --hot --open" }, // ... }3. 简单示例基于上述配置,我们简单实现一个场景: index.js 文件中导入 hello.js 模块,当 hello.js 模块发生变化时, index.js 将更新模块。模块代码如下实现:// hello.js export default () => 'hi leo!'; // index.js import hello from './hello.js' const div = document.createElement('div'); div.innerHTML = hello(); document.body.appendChild(div);然后在 index.html 中导入打包后的 JS 文件,并执行 npm start 运行项目:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <div>了不起的 Webpack HMR 学习指南</div> <script src="bundle.js"></script> </body> </html>4. 实现监听更新当我们通过 HotModuleReplacementPlugin 插件启用了 HMR,则它的接口将被暴露在全局 module.hot 属性下面。通常,可以先检查这个接口是否可访问,然后再开始使用它。举个例子,你可以这样 accept 一个更新的模块:if (module.hot) { module.hot.accept('./library.js', function() { // 使用更新过的 library 模块执行某些操作... }) }关于 module.hot 更多 API ,可以查看官方文档《Hot Module Replacement API》 。回到上面示例,我们测试更新模块的功能。这时我们修改 index.js 代码,来监听 hello.js 模块中的更新:import hello from './hello.js'; const div = document.createElement('div'); div.innerHTML = hello(); document.body.appendChild(div); + if (module.hot) { + module.hot.accept('./hello.js', function() { + console.log('现在在更新 hello 模块了~'); + div.innerHTML = hello(); + }) + }然后修改 hello.js 文件内容,测试效果:- export default () => 'hi leo!'; + export default () => 'hi leo! hello world';当我们保存代码时,控制台输出 "现在在更新 hello模块了~" ,并且页面中 "hi leo!" 也更新为 "hi leo! hello world" ,证明我们监听到文件更新了。简单 Webpack HMR 使用方式就介绍到这,更多介绍,还请阅读官方文档《Hot Module Replacement》。5. devServer 常用配置和技巧5.1 常用配置根据目录结构的不同,contentBase、openPage 参数要配置合适的值,否则运行时应该不会立刻访问到你的首页。 同时要注意你的 publicPath,静态资源打包后生成的路径是一个需要思考的点,取决于你的目录结构。devServer: { contentBase: path.join(__dirname, 'static'), // 告诉服务器从哪里提供内容(默认当前工作目录) openPage: 'views/index.html', // 指定默认启动浏览器时打开的页面 index: 'views/index.html', // 指定首页位置 watchContentBase: true, // contentBase下文件变动将reload页面(默认false) host: 'localhost', // 默认localhost,想外部可访问用'0.0.0.0' port: 8080, // 默认8080 inline: true, // 可以监控js变化 hot: true, // 热启动 open: true, // 启动时自动打开浏览器(指定打开chrome,open: 'Google Chrome') compress: true, // 一切服务都启用gzip 压缩 disableHostCheck: true, // true:不进行host检查 quiet: false, https: false, clientLogLevel: 'none', stats: { // 设置控制台的提示信息 chunks: false, children: false, modules: false, entrypoints: false, // 是否输出入口信息 warnings: false, performance: false, // 是否输出webpack建议(如文件体积大小) }, historyApiFallback: { disableDotRule: true, }, watchOptions: { ignored: /node_modules/, // 略过node_modules目录 }, proxy: { // 接口代理(这段配置更推荐:写到package.json,再引入到这里) "/api-dev": { "target": "http://api.test.xxx.com", "secure": false, "changeOrigin": true, "pathRewrite": { // 将url上的某段重写(例如此处是将 api-dev 替换成了空) "^/api-dev": "" } } }, before(app) { }, }5.2 技巧1:文件形式输出 dev-server 代码dev-server 输出的代码通常在内存中,但也可以写入硬盘,产出实体文件:devServer:{ writeToDisk: true, }通常可以用于代理映射文件调试,编译时会产出许多带 hash 的 js 文件,不带 hash 的文件同样也是实时编译的。5.3 技巧2:默认使用本地 IP 启动服务有的时候,启动服务时,想要默认使用本地的 ip 地址打开:devServer:{ disableHostCheck: true, // true:不进行host检查 // useLocalIp: true, // 建议不在这里配置 // host: '0.0.0.0', // 建议不在这里配置 }同时还需要将 host 配置为 0.0.0.0,这个配置建议在 scripts 命令中追加,而非在配置中写死,否则将来不想要这种方式往回改折腾,取巧一点,配个新命令:"dev-ip": "yarn run dev --host 0.0.0.0 --useLocalIp"5.4 技巧3:指定启动的调试域名有时启动的时候希望是指定的调试域名,例如:local.test.baidu.com:devServer:{ open: true, public: 'local.test.baidu.com:8080', // 需要带上端口 port: 8080, }同时需要将 127.0.0.1 修改为指定的 host,可以借助 iHost 等工具去修改,各个工具大同小异,格式如下:127.0.0.1 local.test.baidu.com服务启动后将自动打开 local.test.baidu.com:8080 访问5.5 技巧4:启动 gzip 压缩devServer:{ compress: true, }三、HMR 基本原理介绍从前面介绍中,我们知道:HMR 主要功能是会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。那么,Webpack 编译源码所产生的文件变化在编译时,替换模块实现在运行时,两者如何联系起来?带着这两个问题,我们先简单看下 HMR 核心工作流程(简化版):HMR 工作流程图.png接下来开始 HMR 工作流程分析:当 Webpack(Watchman) 监听到项目中的文件/模块代码发生变化后,将变化通知 Webpack 中的构建工具(Packager)即 HMR Plugin;然后经过 HMR Plugin 处理后,将结果发送到应用程序(Application)的运行时框架(HMR Runtime);最后由 HMR Runtime 将这些发生变化的文件/模块更新(新增/删除或替换)到模块系统中。其中,HMR Runtime 是构建工具在编译时注入的,通过统一的 Module ID 将编译时的文件与运行时的模块对应起来,并且对外提供一系列 API 供应用层框架(如 React)调用。💖注意💖:建议先理解上面这张图的大致流程,在进行后续阅读。放心,我等着大家~😃
3. 实现 createGraph 函数在 createGraph() 函数中,我们将递归所有依赖模块,循环分析每个依赖模块依赖,生成一份依赖图谱。 为了方便测试,我们补充下 consts.js 和 info.js 文件的代码,增加一些依赖关系:// src/consts.js export const company = "平安"; // src/info.js import { company } from "./consts.js"; export default `你好,${company}`;接下来开始实现 createGraph() 函数,它需要接收一个入口文件的路径( entry )作为参数:// leo_webpack.js function createGraph(entry) { const mainAsset = createAssets(entry); // 获取入口文件下的内容 const queue = [mainAsset]; // 入口文件的结果作为第一项 for(const asset of queue){ const dirname = path.dirname(asset.filename); asset.mapping = {}; asset.dependencies.forEach(relativePath => { const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径 const child = createAssets(absolutePath); asset.mapping[relativePath] = child.id; // 保存模块ID queue.push(child); // 递归去遍历所有子节点的文件 }) } return queue; }上面代码:首先通过 createAssets() 函数读取入口文件的内容,并作为依赖关系的队列(依赖图谱) queue 数组的第一项,接着遍历依赖图谱 queue 每一项,再遍历将每一项中的依赖 dependencies 依赖数组,将依赖中的每一项拼接成依赖的绝对路径(absolutePath ),作为 createAssets() 函数调用的参数,递归去遍历所有子节点的文件,并将结果都保存在依赖图谱 queue 中。注意, mapping 对象是用来保存文件的相对路径和模块 ID 的对应关系,在 mapping 对象中,我们使用依赖文件的相对路径作为 key ,来存储保存模块 ID。然后我们修改启动函数:// leo_webpack.js - const result = createAssets('./src/index.js'); + const graph = createGraph("./src/index.js"); + console.log(graph);这时我们将得到一份包含所有文件依赖关系的依赖图谱:这个依赖图谱,包含了所有文件模块的依赖,以及模块的代码内容。下一步只要实现 bundle() 函数,将结果输出即可。4. 实现 bundle 函数从前面介绍,我们知道,函数 createGraph() 会返回一个包含每个依赖相关信息(id / filename / code / dependencies)的依赖图谱 queue,这一步就将使用到它了。在 bundle() 函数中,接收一个依赖图谱 graph 作为参数,最后输出编译后的结果。4.1 读取所有模块信息我们首先声明一个变量 modules,值为字符串类型,然后对参数 graph 进行遍历,将每一项中的 id 属性作为 key ,值为一个数组,包括一个用来执行代码 code 的方法和序列化后的 mapping,最后拼接到 modules 中。// leo_webpack.js function bundle(graph) { let modules = ""; graph.forEach(item => { modules += ` ${item.id}: [ function (require, module, exports){ ${item.code} }, ${JSON.stringify(item.mapping)} ], ` }) }上面代码:在 modules 中每一项的值中,下标为 0 的元素是个函数,接收三个参数 require / module / exports ,为什么会需要这三个参数呢?原因是:构建工具无法判断是否支持require / module / exports 这三种模块方法,所以需要自己实现(后面步骤会实现),然后方法内的 code 才能正常执行。4.2 返回最终结果接着,我们来实现 bundle() 函数返回值的处理:// leo_webpack.js function bundle(graph) { //... return ` (function(modules){ function require(id){ const [fn, mapping] = modules[id]; function localRequire(relativePath){ return require(mapping[relativePath]); } const module = { exports: {} } fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) ` }上面代码:最终 bundle 函数返回值是一个字符串,包含一个自执行函数(IIFE),其中函数参数是一个对象, key 为 modules , value 为前面拼接好的 modules 字符串,即 {modules: modules字符串} 。在这个自执行函数中,实现了 require 方法,接收一个 id 作为参数,在方法内部,分别实现了 localRequire / module / modules.exports 三个方法,并作为参数,传到 modules[id] 中的 fn 方法中,最后初始化 require() 函数(require(0);)。4.3 代码小结// leo_webpack.js function bundle(graph) { let modules = ""; graph.forEach(item => { modules += ` ${item.id}: [ function (require, module, exports){ ${item.code} }, ${JSON.stringify(item.mapping)} ], ` }) return ` (function(modules){ function require(id){ const [fn, mapping] = modules[id]; function localRequire(relativePath){ return require(mapping[relativePath]); } const module = { exports: {} } fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) ` }5. 执行代码当我们上面方法都实现以后,就开始试试吧:// leo_webpack.js const graph = createGraph("./src/index.js"); const result = bundle(graph); console.log(result)这时候可以看到终端输出类似这样的代码,是字符串,这里为了方便查看而复制到控制台了:这就是打包后的代码咯~那么如何让这些代码执行呢?用 eval() 方法咯:// leo_webpack.js const graph = createGraph("./src/index.js"); const result = bundle(graph); eval(result);这时候就能看到控制台输出 你好,平安 。那么我们就完成一个简单的 Webpack 构建工具啦~能看到这里的朋友,为你点个赞~三、总结本文主要介绍了 Webpack 的构建流程和构建原理,并在此基础上,和大家分享了手写 Webpack 的实现过程,希望大家对 Webpack 构建流程能有更深了解,毕竟面试贼喜欢问啦~
最近原创文章回顾:《了不起的 tsconfig.json 指南》《了不起的 Webpack HMR 学习指南(含源码分析)》《《你不知道的 Blob》番外篇》《《你不知道的 WeakMap》番外篇》Webpack 是前端很火的打包工具,它本质上是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 Webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有模块打包成一个或多个 bundle。其实就是:Webpack 是一个 JS 代码打包器。至于图片、CSS、Less、TS等其他文件,就需要 Webpack 配合 loader 或者 plugin 功能来实现~一、Webpack 构建流程分析1. Webpack 构建过程首先先简单了解下 Webpack 构建过程:根据配置,识别入口文件;逐层识别模块依赖(包括 Commonjs、AMD、或 ES6 的 import 等,都会被识别和分析);Webpack 主要工作内容就是分析代码,转换代码,编译代码,最后输出代码;输出最后打包后的代码。2. Webpack 构建原理看完上面的构建流程的简单介绍,相信你已经简单了解了这个过程,那么接下来开始详细介绍 Webpack 构建原理,包括从启动构建到输出结果一系列过程:(1)初始化参数解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。(2)开始编译上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。(3)确定入口从配置文件( webpack.config.js )中指定的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。(4)编译模块递归中根据文件类型和 loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。(5)完成模块编译并输出递归完后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk 。(6)输出完成输出所有的 chunk 到文件系统。注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩覆盖之前的结果。二、手写 Webpack 构建工具到这里,相信大家对 Webpack 构建流程已经有所了解,但是这还不够,我们再来试着手写 Webpack 构建工具,来将上面文字介绍的内容,应用于实际代码,那么开始吧~1. 初始化项目在手写构建工具前,我们先初始化一个项目:$ yarn init -y并安装下面四个依赖包:@babel/parser : 用于分析通过 fs.readFileSync 读取的文件内容,并返回 AST (抽象语法树) ; @babel/traverse : 用于遍历 AST, 获取必要的数据;@babel/core : babel 核心模块,提供 transformFromAst 方法,用于将 AST 转化为浏览器可运行的代码;@babel/preset-env : 将转换后代码转化成 ES5 代码;$ yarn add @babel/parser @babel/traverse @babel/core @babel/preset-env初始化项目目录及文件: 代码存放在仓库:github.com/pingan8787/…由于本部分核心内容是实现 Webpack 构建工具,所以会从《2. Webpack 构建原理》的“(3)确定入口”步骤开始下面介绍。大致代码实现流程如下:从图中可以看出,手写 Webpack 的核心是实现以下三个方法:createAssets : 收集和处理文件的代码;createGraph :根据入口文件,返回所有文件依赖图;bundle : 根据依赖图整个代码并输出;2. 实现 createAssets 函数2.1 读取通过入口文件,并转为 AST首先在 ./src/index 文件中写点简单代码:// src/index.js import info from "./info.js"; console.log(info);实现 createAssets 方法中的 文件读取 和 AST转换 操作:// leo_webpack.js const fs = require("fs"); const path = require("path"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; // 由于 traverse 采用的 ES Module 导出,我们通过 requier 引入的话就加个 .default const babel = require("@babel/core"); let moduleId = 0; const createAssets = filename => { const content = fs.readFileSync(filename, "utf-8"); // 根据文件名,同步读取文件流 // 将读取文件流 buffer 转换为 AST const ast = parser.parse(content, { sourceType: "module" // 指定源码类型 }) console.log(ast); } createAssets('./src/index.js');上面代码: 通过 fs.readFileSync() 方法,以同步方式读取指定路径下的文件流,并通过 parser 依赖包提供的 parse() 方法,将读取到的文件流 buffer 转换为浏览器可以认识的代码(AST),AST 输出如下:另外需要注意,这里我们声明了一个 moduleId 变量,来区分当前操作的模块。 在这里,不仅将读取到的文件流 buffer 转换为 AST 的同时,也将 ES6 代码转换为 ES5 代码了。2.2 收集每个模块的依赖接下来声明 dependencies 变量来保存收集到的文件依赖路径,通过 traverse() 方法遍历 ast,获取每个节点依赖路径,并 push 进 dependencies 数组中。// leo_webpack.js function createAssets(filename){ // ... const dependencies = []; // 用于收集文件依赖的路径 // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径 traverse(ast, { ImportDeclaration: ({node}) => { dependencies.push(node.source.value); } }); }2.3 将 AST 转换为浏览器可运行代码在收集依赖的同时,我们可以将 AST 代码转换为浏览器可运行代码,这就需要使用到 babel ,这个万能的小家伙,为我们提供了非常好用的 transformFromAstSync() 方法,同步的将 AST 转换为浏览器可运行代码:// leo_webpack.js function createAssets(filename){ // ... const { code } = babel.transformFromAstSync(ast,null, { presets: ["@babel/preset-env"] }); let id = moduleId++; // 设置当前处理的模块ID return { id, filename, code, dependencies } }到这一步,我们在执行 node leo_webpack.js ,输出如下内容,包含了入口文件的路径 filename 、浏览器可执行代码 code 和文件依赖的路径 dependencies 数组:$ node leo_webpack.js { filename: './src/index.js', code: '"use strict";\n\nvar _info = _interopRequireDefault(require("./info.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_info["default"]);', dependencies: [ './info.js' ] }2.4 代码小结// leo_webpack.js const fs = require("fs"); const path = require("path"); const parser = require("@babel/parser"); const traverse = require("@babel/traverse").default; // 由于 traverse 采用的 ES Module 导出,我们通过 requier 引入的话就加个 .default const babel = require("@babel/core"); let moduleId = 0; function createAssets(filename){ const content = fs.readFileSync(filename, "utf-8"); // 根据文件名,同步读取文件流 // 将读取文件流 buffer 转换为 AST const ast = parser.parse(content, { sourceType: "module" // 指定源码类型 }) const dependencies = []; // 用于收集文件依赖的路径 // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径 traverse(ast, { ImportDeclaration: ({node}) => { dependencies.push(node.source.value); } }); // 通过 AST 将 ES6 代码转换成 ES5 代码 const { code } = babel.transformFromAstSync(ast,null, { presets: ["@babel/preset-env"] }); let id = moduleId++; // 设置当前处理的模块ID return { id, filename, code, dependencies } }
最近原创文章回顾😊:《1.2w字 | 初中级前端 JavaScript 自测清单 - 1》《了不起的 Webpack HMR 学习指南(含源码分析)》《了不起的 Webpack 构建流程学习指南》《你不知道的 WeakMap》番外篇《你不知道的 Blob》番外篇《了不起的 tsconfig.json 指南》《200行JS代码,带你实现代码编译器》学习章节:《你不知道的 WeakMap》一、主要知识点原文主要复习了“JavaScript垃圾回收机制”,“Map/WeakMap区别”和“WeakMap 属性和方法”。这很好弥补被我忽视的知识点。另外,我们可以通过原文,以相同方式再去学 Set/WeakSet,效果会更好,本文后面也会介绍到。总结开始,先看原文大纲: 在开始介绍 WeakMap 之前,先复习一遍 JavaScript 中垃圾回收机制,这跟后面的 WeakMap/WeakSet 关系较大。1. 垃圾回收机制垃圾回收(Garbage Collection,缩写为GC)是一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。垃圾回收最早起源于LISP语言。目前许多语言如Smalltalk、Java、C#和D语言都支持垃圾回收器,我们熟知的 JavaScript 具有自动垃圾回收机制。在 JavaScript 中,原始类型的数据被分配到栈空间中,引用类型的数据会被分配到堆空间中。1.1 栈空间中的垃圾回收当函数 showName 调用完成后,通过下移 ESP(Extended Stack Pointer)指针,来销毁 showName 函数,之后调用其他函数时,将覆盖掉旧内存,存放另一个函数的执行上下文,实现垃圾回收。图片来自《浏览器工作原理与实践》1.2 堆空间中的垃圾回收堆中数据垃圾回收策略的基础是:代际假说(The Generational Hypothesis)。即:大部分对象在内存中存在时间极短,很多对象很快就不可访问。不死的对象将活得更久。这两个特点不仅仅适用于 JavaScript,同样适用于大多数的动态语言,如 Java、Python 等。V8 引擎将堆空间分为新生代(存放生存时间短的对象)和老生代(存放生存时间长的对象)两个区域,并使用不同的垃圾回收器。副垃圾回收器,主要负责新生代的垃圾回收。主垃圾回收器,主要负责老生代的垃圾回收。不管是哪种垃圾回收器,都使用相同垃圾回收流程:标记活动对象和非活动对象,回收非活动对象的内存,最后内存整理。1.2.1 副垃圾回收器使用 Scavenge 算法处理,将新生代空间对半分为两个区域,一个对象区域,一个空闲区域。图片来自《浏览器工作原理与实践》执行流程:新对象存在在对象区域,当对象区域将要写满时,执行一次垃圾回收;垃圾回收过程中,首先对对象区域中的垃圾做标记,然后副垃圾回收器将存活的对象复制并有序排列到空闲区域,相当于完成内存整理。复制完成后,将对象区域和空闲区域翻转,完成垃圾回收操作,这也让新生代中两块区域无限重复使用。当然,这也存在一些问题:若复制操作的数据较大则影响清理效率。JavaScript 引擎的解决方式是:将新生代区域设置得比较小,并采用对象晋升策略(经过两次回收仍存活的对象,会被移动到老生区),避免因为新生代区域较小引起存活对象装满整个区域的问题。1.2.2 主垃圾回收器分为:标记 - 清除(Mark-Sweep)算法,和标记 - 整理(Mark-Compact)算法。a)标记 - 清除(Mark-Sweep)算法过程:标记过程:从一组根元素开始遍历整个元素,能到达的元素为活动对象,反之为垃圾数据;清除过程:清理被标记的数据,并产生大量碎片内存。(缺点:导致大对象无法分配到足够的连续内存)图片来自《浏览器工作原理与实践》b)标记 - 整理(Mark-Compact)算法过程:标记过程:从一组根元素开始遍历整个元素,能到达的元素为活动对象,反之为垃圾数据;整理过程:将所有存活的对象,向一段移动,然后清除端边界以外的内容。图片来自《浏览器工作原理与实践》1.3 拓展阅读1.《图解Java 垃圾回收机制》2.《MDN 内存管理》2. Map VS WeakMap2.1 Map 和 WeakMap 主要区别WeakMap 结构与 Map 结构类似,也是用于生成键值对的集合。区别:Map 对象的键可以是任何类型,但 WeakMap 对象中的键只能是对象引用( null 除外);const map = new WeakMap(); map.set(1, 2) // TypeError: 1 is not an object! map.set(Symbol(), 2) // TypeError: Invalid value used as weak map key map.set(null, 2) // TypeError: Invalid value used as weak map keyWeakMap 不能包含无引用的对象,否则会被自动清除出集合(垃圾回收机制);WeakMap 对象没有 size 属性,是不可枚举的,无法获取集合的大小。const map = new WeakMap(); const user1 = {name: 'leo'}; const user2 = {name: 'pingan'}; map.set(user1, 'good~'); map.set(user2, 'hello'); map.map(item => console.log(item)) //Uncaught TypeError: map.map is not a function2.2 Map 缺点和 WeakMap 优点1.赋值和搜索操作都是 O(n) 的时间复杂度,因为这两个操作都需要遍历全部整个数组来进行匹配。2.可能会导致内存泄漏,因为数组会一直引用着每个键和值。相比之下, WeakMap 持有的是每个键对象的 “弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。 原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。2.3 Map 和 WeakMap 垃圾回收对比当数据量越大,则垃圾回收效果越明显。通过命令行执行 node --expose-gc weakmap.js 查看对比效果。其中 --expose-gc 参数表示允许手动执行垃圾回收机制。// weakmap.js const objNum = 10 * 1024 * 1024; const useType = 1; // 修改 useType 值来测试Map和WeakMap const curType = useType == 1 ?"【Map】" : "【WeakMap】"; let arr = new Array(objNum); function usageSize() { const used = process.memoryUsage().heapUsed; return Math.round((used / 1024 / 1024) * 100) / 100 + "M"; } if (useType == 1) { global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); const map = new Map(); map.set(arr, 1); global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); arr = null; global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); console.log("=====") } else { global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); const map = new WeakMap(); global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); arr = null; global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); console.log("=====") }3. WeakMap介绍和应用3.1 WeakMap 介绍WeakMap 对象是一组键/值对的集合,其中的键是 弱引用 的。WeakMap 的 key 只能是 Object 类型。原始数据类型是不能作为 key 的(比如 Symbol)。WeakMap只有四个方法可用:get()、set()、has()、delete()。具体属性和方法介绍,可查看 《MDN WeakMap》。3.2 WeakMap 应用原文中介绍了“通过 WeakMap 缓存计算结果”和“在 WeakMap 中保留私有数据”两种应用场景。另外还有一种比较常见的场景:以 DOM节点作为键名的场景。场景1:当我们想要为DOM添加数据时,可使用 WeakMap 。好处在于,当DOM元素移除时,对应 WeakMap 记录也会自动移除:<div id="WeakMap"></div>const wm = new WeakMap(); const weakMap = document.getElementById('WeakMap'); wm.set(weakMap, 'some information'); wm.get(weakMap) //"some information"场景2:当我们想要为DOM元素添加事件监听时,可使用 WeakMap 。<button id="button1">按钮1</button> <button id="button2">按钮2</button>const button1 = document.getElementById('button1'); const button2 = document.getElementById('button2'); const handler1 = () => { console.log("button1 被点击") }; const handler2 = () => { console.log("button2 被点击") }; // 代码1 button1.addEventListener('click', handler1, false); button2.addEventListener('click', handler2, false); // 代码2 const listener = new WeakMap(); listener.set(button1, handler1); listener.set(button2, handler2); button1.addEventListener('click', listener.get(button1), false); button2.addEventListener('click', listener.get(button2), false);代码2比起代码1的好处是:由于监听函数是放在 WeakMap 里面,则一旦 DOM 对象button1 / button2消失,与它绑定的监听函数handler1和handler2 也会自动消失。二、拓展知识1. 拓展 Set/WeakSet1.1 Set 和 WeakSet 主要区别WeakSet 结构与 Set 类似,也是不重复的值的集合。区别:WeakSet 的成员只能是对象,而不能是其他类型的值;const ws = new WeakSet(); ws.add(1) // TypeError: Invalid value used in weak set ws.add(Symbol()) // TypeError: invalid value used in weak setWeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用;WeakSet 对象没有 size 属性,是不可枚举的,无法获取集合的大小。1.2 Set/WeakSet 垃圾回收对比通过命令行执行 node --expose-gc weakset.js 查看对比效果。// weakset.js const objNum = 5000 * 1024; const useType = 1; const curType = useType == 1 ?"【Set】" : "【WeakSet】"; let obj = []; for (let k = 0; k < objNum; k++) { obj[k] = {} } function usageSize() { const used = process.memoryUsage().heapUsed; return Math.round((used / 1024 / 1024) * 100) / 100 + "M"; } if (useType == 1) { global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); const sets = new Set([...obj]); global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); obj = null; global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); console.log("=====") } else { global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); const sets = new WeakSet(obj); global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); obj = null; global.gc(); console.log(objNum + '个' + curType + '占用内存:' + usageSize()); console.log("=====") }三、总结本文首先复习了《你不知道的 WeakMap》中核心知识点,重新回顾了“垃圾回收机制”,“Map VS WeakMap”和“WeakMap 介绍和应用”,最后延伸复习了“Set/WeakSet”相关知识点。在实际业务开发中,最好也能考虑垃圾回收机制的合理使用,这也是提升产品性能的一个非常常用的方式。语雀知识库:Cute-FrontEnd
4. 从互联网下载数据在实现“从互联网下载数据”方法时,我们使用 createObjectURL 显示图片,在请求互联网图片时,我们有两种方式:使用 XMLHttpRequest ;使用 fetch ;<body> <button onclick="download1()">使用 XMLHttpRequest 下载</button> <button onclick="download2()">使用 fetch 下载</button> <img id="pingan"> <script> const url = "http://images.pingan8787.com/TinyCompiler/111.png"; const pingan = document.querySelector('#pingan'); function download1 (){ const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.responseType = 'blob'; xhr.onload = () => { renderImage(xhr.response); } xhr.send(null); } function download2 (){ fetch(url).then(res => { return res.blob(); }).then(myBlob => { renderImage(myBlob); }) } function renderImage (data){ let objectURL = URL.createObjectURL(data); pingan.src = objectURL; // 根据业务需要手动调用 URL.revokeObjectURL(imgUrl) } </script> </body>5. 下载文件通过调用 Blob 的构造函数来创建类型为 "text/plain" 的 Blob 对象,然后通过动态创建 a 标签来实现文件的下载。<body> <button onclick="download()">Blob 文件下载</button> <script> function download(){ const fileName= "Blob文件.txt"; const myBlob = new Blob(["一文彻底掌握 Blob Web API"], { type: "text/plain" }); downloadFun(fileName, myBlob); } function downloadFun(fileName, blob){ const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = fileName; link.click(); link.remove(); URL.revokeObjectURL(link.href); } </script> </body>6. 图片压缩当我们希望本地图片在上传之前,先进行一定压缩,再提交,从而减少传输的数据量。在前端我们可以使用 Canvas 提供的 toDataURL() 方法来实现,该方法接收 type 和 encoderOptions 两个可选参数:type 表示图片格式,默认为 image/png ;encoderOptions 表示图片质量,在指定图片格式为 image/jpeg 或 image/webp 的情况下,可以从 0 到 1 区间内选择图片质量。如果超出取值范围,将会使用默认值 0.92,其他参数会被忽略。<body> <input type="file" accept="image/*" onchange="loadFile(event)" /> <script> // compress.js const MAX_WIDTH = 800; // 图片最大宽度 // 图片压缩方法 function compress(base64, quality, mimeType) { let canvas = document.createElement("canvas"); let img = document.createElement("img"); img.crossOrigin = "anonymous"; return new Promise((resolve, reject) => { img.src = base64; img.onload = () => { let targetWidth, targetHeight; if (img.width > MAX_WIDTH) { targetWidth = MAX_WIDTH; targetHeight = (img.height * MAX_WIDTH) / img.width; } else { targetWidth = img.width; targetHeight = img.height; } canvas.width = targetWidth; canvas.height = targetHeight; let ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, targetWidth, targetHeight); // 清除画布 ctx.drawImage(img, 0, 0, canvas.width, canvas.height); let imageData = canvas.toDataURL(mimeType, quality / 100); // 设置图片质量 resolve(imageData); }; }); } // 为了进一步减少传输的数据量,我们可以把它转换为 Blob 对象 function dataUrlToBlob(base64, mimeType) { let bytes = window.atob(base64.split(",")[1]); let ab = new ArrayBuffer(bytes.length); let ia = new Uint8Array(ab); for (let i = 0; i < bytes.length; i++) { ia[i] = bytes.charCodeAt(i); } return new Blob([ab], { type: mimeType }); } // 通过 AJAX 提交到服务器 function uploadFile(url, blob) { let formData = new FormData(); let request = new XMLHttpRequest(); formData.append("image", blob); request.open("POST", url, true); request.send(formData); } function loadFile(event) { const reader = new FileReader(); reader.onload = async function () { let compressedDataURL = await compress( reader.result, 90, "image/jpeg" ); let compressedImageBlob = dataUrlToBlob(compressedDataURL); uploadFile("https://httpbin.org/post", compressedImageBlob); }; reader.readAsDataURL(event.target.files[0]); }; </script> </body>其实 Canvas 对象除了提供 toDataURL() 方法之外,它还提供了一个 toBlob() 方法,该方法的语法如下:canvas.toBlob(callback, mimeType, qualityArgument)和 toDataURL() 方法相比,toBlob() 方法是异步的,因此多了个 callback 参数,这个 callback 回调方法默认的第一个参数就是转换好的 blob文件信息。7. 生成 PDF 文档在浏览器端,利用一些现成的开源库,比如 jsPDF,我们也可以方便地生成 PDF 文档。 <body> <h3>客户端生成 PDF 示例</h3> <script src="https://unpkg.com/jspdf@latest/dist/jspdf.min.js"></script> <script> (function generatePdf() { const doc = new jsPDF(); doc.text("Hello semlinker!", 66, 88); const blob = new Blob([doc.output()], { type: "application/pdf" }); blob.text().then((blobAsText) => { console.log(blobAsText); }); })(); </script> </body>其实 jsPDF 除了支持纯文本之外,它也可以生成带图片的 PDF 文档,比如:let imgData = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/...' let doc = new jsPDF(); doc.setFontSize(40); doc.text(35, 25, 'Paranyan loves jsPDF'); doc.addImage(imgData, 'JPEG', 15, 40, 180, 160);四、Blob 与 ArrayBuffer 有何区别?1. 定义区别ArrayBuffer 对象用于表示通用的,固定长度的原始二进制数据缓冲区。且不能直接操纵 ArrayBuffer 的内容,需要创建一个类型化数组对象或 DataView 对象,该对象以特定格式表示缓冲区,并使用该对象读取和写入缓冲区的内容。Blob 类型的对象表示不可变的类似文件对象的原始数据。Blob 表示的不一定是 JavaScript 原生格式的数据。File 接口基于 Blob,继承了Blob 功能并将其扩展为支持用户系统上的文件。Blob 类型只有 slice 方法,用于返回一个新的 Blob 对象,包含了源 Blob 对象中指定范围内的数据。 对比发现,ArrayBuffer 的数据,是可以按照字节去操作的,而 Blob 只能作为一个完整对象去处理。所以说,ArrayBuffer 相比 Blob 更接近真实的二进制,更底层。2. 两者互转2.1 ArrayBuffer 转 Blob只需将 ArrayBuffer 作为参数传入即可:const buffer = new ArrayBuffer(16); const blob = new Blob([buffer]);2.2 Blob 转 ArrayBuffer需要借助 FileReader 对象:const blob = new Blob([1,2,3,4,5]); const reader = new FileReader(); reader.onload = function() { console.log(this.result); } reader.readAsArrayBuffer(blob);3. 其他区别需要使用写入/编辑操作时使用 ArrayBuffer,否则使用 Blob 即可;Blob 对象不可变,而 ArrayBuffer 可以通过 TypedArrays 或 DataView 操作;Blob 可以位于磁盘、高速缓存内存和其他不同用位置,而 ArrayBuffer 存在内存中,可以直接操作;4. Ajax 中使用 Blob 和 ArrayBufferfunction GET(url, callback) { let xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.responseType = 'arraybuffer'; // or xhr.responseType = "blob"; xhr.send(); xhr.onload = function(e) { if (xhr.status != 200) { alert("Unexpected status code " + xhr.status + " for " + url); return false; } callback(new Uint8Array(xhr.response)); // or new Blob([xhr.response]); }; }五、拓展1. Blob URL 和 Data URL 区别1.1 格式不同Blob URL 格式如 blob:域名/uuid , Data URL 格式如: data:[<mediatype>][;base64],<data> 。mediatype 是个 MIME 类型的字符串,例如 "image/jpeg" 表示 JPEG 图像文件。如果被省略,则默认值为 text/plain;charset=US-ASCII。1.2 长度不同Blob URL 一般长度较短,而 Data URL 因为直接存储图片 base64 编码后的数据,往往比较长。1.3 XMLHttpRequest 支持情况不同Blob URL 可以很方便使用 XMLHttpRequest 获取源数据( xhr.responseType = 'blob' ),而 Data URL 并不是所有浏览器都支持通过 XMLHttpRequest 获取源数据的。1.4 使用场景不同Blob URL 只能在当前应用内使用,把 Blob URL 复制到浏览器地址栏是无法获取数据,而 Data URL 则可以在任意浏览器中使用。六、总结本文中我们主要通过 4 个问题来复习了 Blob 知识点:“Blob 是什么”、“Blob 怎么用”、“Blob 使用场景”和“Blob 与 ArrayBuffer 区别”,在“Blob 使用场景”部分中,也主要介绍了我们实际开发中非常常见的“图片预览”、“图片下载”和“生成文件”的场景。在文章最后,也通过和大家复习了“Blob URL 和 Data URL 区别”,让我们对 Blob 有更深的认识。本文使用 mdnice 排版
最近原创文章回顾😊:《1.2w字 | 初中级前端 JavaScript 自测清单 - 1》《了不起的 Webpack HMR 学习指南(含源码分析)》《了不起的 Webpack 构建流程学习指南》《你不知道的 WeakMap》番外篇《你不知道的 Blob》番外篇《了不起的 tsconfig.json 指南》《200行JS代码,带你实现代码编译器》学习章节:《你不知道的 Blob》原文对 Blob 的知识点介绍得非常完整清晰,本文通过四个问题来总结本文核心知识:Blob 是什么?Blob 怎么用?Blob 有哪些使用场景?Blob 与 ArrayBuffer 有何区别?一、Blob 是什么?Blob(Binary Large Object)表示二进制类型的大对象,通常是影像、声音或多媒体文件。MySql/Oracle数据库中,就有一种Blob类型,专门存放二进制数据。在 JavaScript 中 Blob 对象表示一个不可变、原始数据的类文件对象,它不一定非得是大量数据,也可以表示一个小型文件的内容。另外,JavaScript 中的 File 接口是基于 Blob,继承 Blob 的功能并将其扩展使其支持用户系统上的文件。二、Blob 怎么用?Blob 由一个可选字符串 type 和 blobParts 组成,其中, type 通常为 MIME 类型。MIME(Multipurpose Internet Mail Extensions)多用途互联网邮件扩展类型,常见有:超文本标记语言文本 .html text/html 、PNG图像 .png image/png 、普通文本 .txt text/plain 等。1. 构造函数Blob 构造函数语法为:const myBlob = new Blob(blobParts[, options])入参:blobParts:它是一个由 ArrayBuffer,ArrayBufferView,Blob,DOMString 等对象构成的数组。DOMStrings 会被编码为 UTF-8。options:一个可选的对象,包含以下两个属性:type :默认值为 "" ,表示将会被放入到 blob 中的数组内容的 MIME 类型。endings :默认值为 "transparent",用于指定包含行结束符 \n 的字符串如何被写入。它是以下两个值中的一个:"native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持 blob 中保存的结束符不变。出参:返回一个新创建的 Blob 对象,其内容由参数中给定的数组串联组成。2. 属性和方法2.1 属性介绍Blob 对象拥有 2 个属性:size :只读,表示 Blob 对象中所包含的数据大小(以字节为单位);type :只读,值为字符串,表示该 Blob 对象所包含数据的 MIME 类型。若类型未知,则该属性值为空字符串。2.2 方法介绍slice([start[, end[, contentType]]]) :返回一个新的 Blob 对象,包含了源 Blob 对象中指定范围内的数据。stream():返回一个能读取 Blob 内容的 ReadableStream 。text():返回一个 Promise 对象且包含 Blob 所有内容的 UTF-8 格式的 USVString 。arrayBuffer():返回一个 Promise 对象且包含 Blob 所有内容的二进制格式的 ArrayBuffer 。注意:** Blob 对象是不可改变的**,但是可以进行分割,并创建出新的 Blob 对象,将它们混合到一个新的 Blob 中。类似于 JavaScript 字符串:我们无法更改字符串中的字符,但可以创建新的更正后的字符串。3. 简单上手3.1 示例1:从字符串创建 Bloblet myBlobParts = ['<html><h2>Hello Leo</h2></html>']; // 一个包含DOMString的数组 let myBlob = new Blob(myBlobParts, {type : 'text/html', endings: "transparent"}); // 得到 blob console.log(myBlob.size + " bytes size"); // Output: 31 bytes size console.log(myBlob.type + " is the type"); // Output: text/html is the type3.2 示例2:从类型化数组和字符串创建 BlobJavaScript类型化数组是一种类似数组的对象,并提供了一种用于 访问原始二进制数据的机制 。并且在类型数组上调用 Array.isArray() 会返回 false 。详细可参考MDN《JavaScript 类型化数组》章节。let hello = new Uint8Array([72, 101, 108, 108, 111]); // 二进制格式的 "hello" let blob = new Blob([hello, ' ', 'leo'], {type: 'text/plain'}); // Output: "Hello leo"3.3 示例3:组装新的 Blob由于 Blob 对象是不可改变的,但我们可以进行分割,并组装成一个新的 Blob 对象:let blob1 = new Blob(['<html><h2>Hello Leo</h2></html>'], {type : 'text/html', endings: "transparent"}); let blob2 = new Blob(['<html><h2>Happy Boy!</h2></html>'], {type : 'text/html', endings: "transparent"}); let slice1 = blob1.slice(16); let slice2 = blob2.slice(0, 16); await slice1.text(); // currtent slice1 value: "Leo</h2></html>" await slice2.text(); // currtent slice2 value: "<html><h2>Happy " let newBlob = new Blob([slice2, slice1], {type : 'text/html', endings: "transparent"}); await newBlob.text(); // currtent newBlob value: "<html><h2>Happy Leo</h2></html>"三、Blob 有哪些使用场景?1. 图片本地预览这里整理 2 种图片本地预览的方式:使用 DataURL 方式;使用 Blob URL/Object URL 方式;<body> <h1>1.DataURL方式:</h1> <input type="file" accept="image/*" onchange="selectFileForDataURL(event)"> <img id="output1"> <h1>2.Blob方式:</h1> <input type="file" accept="image/*" onchange="selectFileForBlob(event)"> <img id="output2"> <script> // 1.DataURL方式: async function selectFileForDataURL() { const reader = new FileReader(); reader.onload = function () { const output = document.querySelector("#output1") output.src = reader.result; } reader.readAsDataURL(event.target.files[0]); } //2.Blob方式: async function selectFileForBlob(){ const reader = new FileReader(); const output = document.querySelector("#output2"); const imgUrl = window.URL.createObjectURL(event.target.files[0]); output.src = imgUrl; reader.onload = function(event){ window.URL.revokeObjectURL(imgUrl); } } </script> </body>上面主要介绍 Blob URL 和 Data URL 两种方式实现图片本地预览,这两个类型的区别在**《五、拓展》**中介绍。2. 图片本地预览 + 分片上传实现本地预览:将 input 获取到的 file 对象,通过实例化 FileReader ,赋值给变量 reader ,调用reader 的 readAsDataURL 方法,将 file 对象转换为 dataURL ,然后监听 reader 的 onload 属性,获取到读取结果 result ,然后设置为图片的 src 值。实现分片上传:由于 File 是特殊类型的 Blob,可用于任意 Blob 类型的上下文,所以针对大文件传输,我们可以使用 slice 方法进行文件切割,分片上传。<body> <input type="file" accept="image/*" onchange="selectFile(event)"> <button onclick="upload()">上传</button> <img id="output"> <script> const chunkSize = 10000; const url = "https://httpbin.org/post"; async function selectFile(){ // 本地预览 const reader = new FileReader(); reader.onload = function(){ const output = document.querySelector("#output") output.src = reader.result; } reader.readAsDataURL(event.target.files[0]); // 分片上传 await upload(event.target.files[0]); } async function upload(files){ const file = files; for(let start = 0; start < file.size; start += chunkSize){ const chunk = file.slice(start, start + chunkSize + 1); const fd = new FormData(); fd.append("data", chunk); await fetch(url, { method: "post", body: fd }).then((res) =>{ console.log(res) res.text(); }); } } </script> </body>3. 图片本地预览 + 分片上传 + 暂停 + 续传<body> <input type="file" accept="image/*" onchange="selectFile(event)"> <button onclick="upload()">上传</button> <button onclick="pause()">暂停</button> <button onclick="continues()">继续</button> <img id="output" src="" alt=""> <script> const chunkSize = 30000; let start = 0, curFile, isPause = false; const url = "https://httpbin.org/post"; async function selectFile(){ const reader = new FileReader(); reader.onload = function(){ const output = document.querySelector("#output") output.src = reader.result; } reader.readAsDataURL(event.target.files[0]); curFile = event.target.files[0]; } async function upload(){ const file = curFile; for(start; start < file.size; start += chunkSize){ if(isPause) return; const chunk = file.slice(start, start + chunkSize + 1); const fd = new FormData(); fd.append("data", chunk); await fetch(url, { method: "post", body: fd }).then((res) =>{ res.text() } ); if(chunk.size < chunkSize){ uploadSuccess(); return; } } } function pause(){ isPause = true; } function continues(){ isPause = false; upload(); } function uploadSuccess(){ isPause = false; start = 0; } </script> </body>
五、tsconfig.json 配置介绍1. compileOnSavecompileOnSave 属性作用是设置保存文件的时候自动编译,但需要编译器支持。{ // ... "compileOnSave": false, }2. compilerOptionscompilerOptions 属性作用是配置编译选项。若 compilerOptions 属性被忽略,则编译器会使用默认值,可以查看《官方完整的编译选项列表》。编译选项配置非常繁杂,有很多配置,这里只列出常用的配置。{ // ... "compilerOptions": { "incremental": true, // TS编译器在第一次编译之后会生成一个存储编译信息的文件,第二次编译会在第一次的基础上进行增量编译,可以提高编译的速度 "tsBuildInfoFile": "./buildFile", // 增量编译文件的存储位置 "diagnostics": true, // 打印诊断信息 "target": "ES5", // 目标语言的版本 "module": "CommonJS", // 生成代码的模板标准 "outFile": "./app.js", // 将多个相互依赖的文件生成一个文件,可以用在AMD模块中,即开启时应设置"module": "AMD", "lib": ["DOM", "ES2015", "ScriptHost", "ES2019.Array"], // TS需要引用的库,即声明文件,es5 默认引用dom、es5、scripthost,如需要使用es的高级版本特性,通常都需要配置,如es8的数组新特性需要引入"ES2019.Array", "allowJS": true, // 允许编译器编译JS,JSX文件 "checkJs": true, // 允许在JS文件中报错,通常与allowJS一起使用 "outDir": "./dist", // 指定输出目录 "rootDir": "./", // 指定输出文件目录(用于输出),用于控制输出目录结构 "declaration": true, // 生成声明文件,开启后会自动生成声明文件 "declarationDir": "./file", // 指定生成声明文件存放目录 "emitDeclarationOnly": true, // 只生成声明文件,而不会生成js文件 "sourceMap": true, // 生成目标文件的sourceMap文件 "inlineSourceMap": true, // 生成目标文件的inline SourceMap,inline SourceMap会包含在生成的js文件中 "declarationMap": true, // 为声明文件生成sourceMap "typeRoots": [], // 声明文件目录,默认时node_modules/@types "types": [], // 加载的声明文件包 "removeComments":true, // 删除注释 "noEmit": true, // 不输出文件,即编译后不会生成任何js文件 "noEmitOnError": true, // 发送错误时不输出任何文件 "noEmitHelpers": true, // 不生成helper函数,减小体积,需要额外安装,常配合importHelpers一起使用 "importHelpers": true, // 通过tslib引入helper函数,文件必须是模块 "downlevelIteration": true, // 降级遍历器实现,如果目标源是es3/5,那么遍历器会有降级的实现 "strict": true, // 开启所有严格的类型检查 "alwaysStrict": true, // 在代码中注入'use strict' "noImplicitAny": true, // 不允许隐式的any类型 "strictNullChecks": true, // 不允许把null、undefined赋值给其他类型的变量 "strictFunctionTypes": true, // 不允许函数参数双向协变 "strictPropertyInitialization": true, // 类的实例属性必须初始化 "strictBindCallApply": true, // 严格的bind/call/apply检查 "noImplicitThis": true, // 不允许this有隐式的any类型 "noUnusedLocals": true, // 检查只声明、未使用的局部变量(只提示不报错) "noUnusedParameters": true, // 检查未使用的函数参数(只提示不报错) "noFallthroughCasesInSwitch": true, // 防止switch语句贯穿(即如果没有break语句后面不会执行) "noImplicitReturns": true, //每个分支都会有返回值 "esModuleInterop": true, // 允许export=导出,由import from 导入 "allowUmdGlobalAccess": true, // 允许在模块中全局变量的方式访问umd模块 "moduleResolution": "node", // 模块解析策略,ts默认用node的解析策略,即相对的方式导入 "baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录 "paths": { // 路径映射,相对于baseUrl // 如使用jq时不想使用默认版本,而需要手动指定版本,可进行如下配置 "jquery": ["node_modules/jquery/dist/jquery.min.js"] }, "rootDirs": ["src","out"], // 将多个目录放在一个虚拟目录下,用于运行时,即编译后引入文件的位置可能发生变化,这也设置可以虚拟src和out在同一个目录下,不用再去改变路径也不会报错 "listEmittedFiles": true, // 打印输出文件 "listFiles": true// 打印编译的文件(包括引用的声明文件) } }3. excludeexclude 属性作用是指定编译器需要排除的文件或文件夹。默认排除 node_modules 文件夹下文件。{ // ... "exclude": [ "src/lib" // 排除src目录下的lib文件夹下的文件不会编译 ] }和 include 属性一样,支持 glob 通配符:* 匹配0或多个字符(不包括目录分隔符)? 匹配一个任意字符(不包括目录分隔符)**/ 递归匹配任意子目录4. extendsextends 属性作用是引入其他配置文件,继承配置。默认包含当前目录和子目录下所有 TypeScript 文件。{ // ... // 把基础配置抽离成tsconfig.base.json文件,然后引入 "extends": "./tsconfig.base.json" }5. filesfiles 属性作用是指定需要编译的单个文件列表。默认包含当前目录和子目录下所有 TypeScript 文件。{ // ... "files": [ // 指定编译文件是src目录下的leo.ts文件 "scr/leo.ts" ] }6. includeinclude 属性作用是指定编译需要编译的文件或目录。{ // ... "include": [ // "scr" // 会编译src目录下的所有文件,包括子目录 // "scr/*" // 只会编译scr一级目录下的文件 "scr/*/*" // 只会编译scr二级目录下的文件 ] }7. referencesreferences 属性作用是指定工程引用依赖。 在项目开发中,有时候我们为了方便将前端项目和后端node项目放在同一个目录下开发,两个项目依赖同一个配置文件和通用文件,但我们希望前后端项目进行灵活的分别打包,那么我们可以进行如下配置:{ // ... "references": [ // 指定依赖的工程 {"path": "./common"} ] }8. typeAcquisitiontypeAcquisition 属性作用是设置自动引入库类型定义文件(.d.ts)相关。 包含 3 个子属性:enable : 布尔类型,是否开启自动引入库类型定义文件(.d.ts),默认为 false;include : 数组类型,允许自动引入的库名,如:["jquery", "lodash"];exculde : 数组类型,排除的库名。{ // ... "typeAcquisition": { "enable": false, "exclude": ["jquery"], "include": ["jest"] } }六、常见配置示例本部分内容中,我们找了几个实际开发中比较常见的配置,当然,还有很多配置需要自己摸索哟~~1. 移除代码中注释tsconfig.json:{ "compilerOptions": { "removeComments": true, } }编译前:// 返回当前版本号 function getVersion(version:string = "1.0.0"): string{ return version; } console.log(getVersion("1.0.1"))编译结果:function getVersion(version) { if (version === void 0) { version = "1.0.0"; } return version; } console.log(getVersion("1.0.1"));2. 开启null、undefined检测tsconfig.json:{ "compilerOptions": { "strictNullChecks": true }, }修改 index.ts 文件内容:const leo; leo = new Pingan('leo','hello');这时候编辑器也会提示错误信息,执行 tsc 后,控制台报错:src/index.ts:9:11 - error TS2304: Cannot find name 'Pingan'. 9 leo = new Pingan('leo','hello'); Found 1 error.3. 配置复用通过 extends 属性实现配置复用,即一个配置文件可以继承另一个文件的配置属性。比如,建立一个基础的配置文件 configs/base.json :{ "compilerOptions": { "noImplicitAny": true, "strictNullChecks": true } }在tsconfig.json 就可以引用这个文件的配置了:{ "extends": "./configs/base", "files": [ "main.ts", "supplemental.ts" ] }4. 生成枚举的映射代码在默认情况下,使用 const 修饰符后,枚举不会生成映射代码。如下,我们可以看出:使用 const 修饰符后,编译器不会生成任何 RequestMethod 枚举的任何映射代码,在其他地方使用时,内联每个成员的值,节省很大开销。const enum RequestMethod { Get, Post, Put, Delete } let methods = [ RequestMethod.Get, RequestMethod.Post ]编译结果:"use strict"; let methods = [ 0 /* Get */, 1 /* Post */ ];当然,我们希望生成映射代码时,也可以设置 tsconfig.json 中的配置,设置 preserveConstEnums 编译器选项为 true :{ "compilerOptions": { "target": "es5", "preserveConstEnums": true } }最后编译结果变成:"use strict"; var RequestMethod; (function (RequestMethod) { RequestMethod[RequestMethod["Get"] = 0] = "Get"; RequestMethod[RequestMethod["Post"] = 1] = "Post"; RequestMethod[RequestMethod["Put"] = 2] = "Put"; RequestMethod[RequestMethod["Delete"] = 3] = "Delete"; })(RequestMethod || (RequestMethod = {})); let methods = [ 0 /* Get */, 1 /* Post */ ];5. 关闭 this 类型注解提示通过下面代码编译后会报错:const button = document.querySelector("button"); button?.addEventListener("click", handleClick); function handleClick(this) { console.log("Clicked!"); this.removeEventListener("click", handleClick); }报错内容:src/index.ts:10:22 - error TS7006: Parameter 'this' implicitly has an 'any' type. 10 function handleClick(this) { Found 1 error.这是因为 this 隐式具有 any 类型,如果没有指定类型注解,编译器会提示“"this" 隐式具有类型 "any",因为它没有类型注释。”。解决方法有2种:指定 this 类型,如本代码中为 HTMLElement 类型:HTMLElement 接口表示所有的 HTML 元素。一些HTML元素直接实现了 HTMLElement 接口,其它的间接实现HTMLElement接口。 关于 HTMLElement 可查看详细。使用 --noImplicitThis 配置项: 在 TS2.0 还增加一个新的编译选项: --noImplicitThis,表示当 this 表达式值为 any 类型时生成一个错误信息。我们设置为 true 后就能正常编译。{ "compilerOptions": { "noImplicitThis": true } }七、Webpack/React 中使用示例1. 配置编译 ES6 代码,JSX 文件创建测试项目 webpack-demo,结构如下:webpack-demo/ |- package.json |- tsconfig.json |- webpack.config.js |- /dist |- bundle.js |- index.html |- /src |- index.js |- index.ts |- /node_modules安装 TypeScript 和 ts-loader:$ npm install --save-dev typescript ts-loader配置 tsconfig.json,支持 JSX,并将 TypeScript 编译为 ES5:{ "compilerOptions": { "outDir": "./dist/", "noImplicitAny": true, + "module": "es6", + "target": "es5", + "jsx": "react", "allowJs": true } }还需要配置 webpack.config.js,使其能够处理 TypeScript 代码,这里主要在 rules 中添加 ts-loader :const path = require('path'); module.exports = { entry: './src/index.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ } ] }, resolve: { extensions: [ '.tsx', '.ts', '.js' ] }, output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') } };2. 配置 source map想要启用 source map,我们必须配置 TypeScript,以将内联的 source map 输出到编译后的 JavaScript 文件中。只需要在 tsconfig.json 中配置 sourceMap 属性: { "compilerOptions": { "outDir": "./dist/", + "sourceMap": true, "noImplicitAny": true, "module": "commonjs", "target": "es5", "jsx": "react", "allowJs": true } }然后配置 webpack.config.js 文件,让 webpack 提取 source map,并内联到最终的 bundle 中: const path = require('path'); module.exports = { entry: './src/index.ts', + devtool: 'inline-source-map', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ } ] }, resolve: { extensions: [ '.tsx', '.ts', '.js' ] }, output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') } };八、总结本文较全面介绍了 tsconfig.json 文件的知识,从“什么是 tsconfig.js 文件”开始,一步步带领大家全面认识 tsconfig.json 文件。 文中通过一个简单 learnTsconfig 项目,让大家知道项目中如何使用 tsconfig.json 文件。在后续文章中,我们将这么多的配置项进行分类学习。最后通过几个常见配置示例,解决我们开发中遇到的几个常见问题。当然,本文篇幅有限,无法针对每个属性进行深入介绍,这就需要大家在实际开发中,多去尝试和使用啦~九、学习和参考资料1.《Intro to the TSConfig Reference》 2.《tsconfig.json》 3.《TypeScript编译器的配置文件的JSON模式》 4.《详解TypeScript项目中的tsconfig.json配置》 5.《官方完整的编译选项列表》
在 TypeScript 开发中,tsconfig.json 是个不可或缺的配置文件,它是我们在 TS 项目中最常见的配置文件,那么你真的了解这个文件吗?它里面都有哪些优秀配置?如何配置一个合理的 tsconfig.json 文件?本文将全面带大家一起详细了解 tsconfig.json 的各项配置。本文将从以下几个方面全面介绍 tsconfig.json 文件: 水平有限,欢迎各位大佬指点~~一、tsconfig.json 简介1. 什么是 tsconfig.jsonTypeScript 使用 tsconfig.json 文件作为其配置文件,当一个目录中存在 tsconfig.json 文件,则认为该目录为 TypeScript 项目的根目录。通常 tsconfig.json 文件主要包含两部分内容:指定待编译文件和定义编译选项。从《TypeScript编译器的配置文件的JSON模式》可知,目前 tsconfig.json 文件有以下几个顶层属性:compileOnSavecompilerOptionsexcludeextendsfilesincludereferencestypeAcquisition文章后面会详细介绍一些常用属性配置。2. 为什么使用 tsconfig.json通常我们可以使用 tsc 命令来编译少量 TypeScript 文件:/* 参数介绍: --outFile // 编译后生成的文件名称 --target // 指定ECMAScript目标版本 --module // 指定生成哪个模块系统代码 index.ts // 源文件 */ $ tsc --outFile leo.js --target es3 --module amd index.ts但如果实际开发的项目,很少是只有单个文件,当我们需要编译整个项目时,就可以使用 tsconfig.json 文件,将需要使用到的配置都写进 tsconfig.json 文件,这样就不用每次编译都手动输入配置,另外也方便团队协作开发。二、使用 tsconfig.json目前使用 tsconfig.json 有2种操作:1. 初始化 tsconfig.json在初始化操作,也有 2 种方式:手动在项目根目录(或其他)创建 tsconfig.json 文件并填写配置;通过 tsc --init 初始化 tsconfig.json 文件。2. 指定需要编译的目录在不指定输入文件的情况下执行 tsc 命令,默认从当前目录开始编译,编译所有 .ts 文件,并且从当前目录开始查找 tsconfig.json 文件,并逐级向上级目录搜索。$ tsc另外也可以为 tsc 命令指定参数 --project 或 -p 指定需要编译的目录,该目录需要包含一个 tsconfig.json 文件,如:/* 文件目录: ├─src/ │ ├─index.ts │ └─tsconfig.json ├─package.json */ $ tsc --project src注意,tsc 的命令行选项具有优先级,会覆盖 tsconfig.json 中的同名选项。更多 tsc 编译选项,可查看《编译选项》章节。三、使用示例这个章节,我们将通过本地一个小项目 learnTsconfig 来学着实现一个简单配置。当前开发环境:windows / node 10.15.1 / TypeScript3.91. 初始化 learnTsconfig 项目执行下面命令:$ mkdir learnTsconfig $ cd .\learnTsconfig\ $ mkdir src $ new-item index.ts并且我们为 index.ts 文件写一些简单代码:// 返回当前版本号 function getVersion(version:string = "1.0.0"): string{ return version; } console.log(getVersion("1.0.1"))我们将获得这么一个目录结构: └─src/ └─index.ts2. 初始化 tsconfig.json 文件在 learnTsconfig 根目录执行:$ tsc --init3. 修改 tsconfig.json 文件我们设置几个常见配置项:{ "compilerOptions": { "target": "ES5", // 目标语言的版本 "module": "commonjs", // 指定生成代码的模板标准 "noImplicitAny": true, // 不允许隐式的 any 类型 "removeComments": true, // 删除注释 "preserveConstEnums": true, // 保留 const 和 enum 声明 "sourceMap": true // 生成目标文件的sourceMap文件 }, "files": [ // 指定待编译文件 "./src/index.ts" ] }其中需要注意一点: files 配置项值是一个数组,用来指定了待编译文件,即入口文件。当入口文件依赖其他文件时,不需要将被依赖文件也指定到 files 中,因为编译器会自动将所有的依赖文件归纳为编译对象,即 index.ts 依赖 user.ts 时,不需要在 files 中指定 user.ts , user.ts 会自动纳入待编译文件。4. 执行编译配置完成后,我们可以在命令行执行 tsc 命令,执行编译完成后,我们可以得到一个 index.js 文件和一个 index.js.map 文件,证明我们编译成功,其中 index.js 文件内容如下:function getVersion(version) { if (version === void 0) { version = "1.0.0"; } return version; } console.log(getVersion("1.0.1")); //# sourceMappingURL=index.js.map可以看出,tsconfig.json 中的 removeComments 配置生效了,将我们添加的注释代码移除了。到这一步,就完成了这个简单的示例,接下来会基于这个示例代码,讲解《七、常见配置示例》。四、tsconfig.json 文件结构介绍1. 按顶层属性分类在 tsconfig.json 文件中按照顶层属性,分为以下几类:2. 按功能分类
词法分析器词法分析器方法 tokenizer 的主要任务:遍历整个原始代码字符串,将原始代码字符串转换为词法单元数组(tokens),并返回。在遍历过程中,匹配每种字符并处理成词法单元压入词法单元数组,如当匹配到左括号( ( )时,将往词法单元数组(tokens)压入一个词法单元对象({type: 'paren', value:'('})。// 词法分析器 参数:原始代码字符串 input function tokenizer(input) { let current = 0; // 当前解析的字符索引,作为游标 let tokens = []; // 初始化词法单元数组 // 循环遍历原始代码字符串,读取词法单元数组 while (current < input.length) { let char = input[current]; // 匹配左括号,匹配成功则压入对象 {type: 'paren', value:'('} if (char === '(') { tokens.push({ type: 'paren', value: '(' }); current++; continue; // 自增current,完成本次循环,进入下一个循环 } // 匹配右括号,匹配成功则压入对象 {type: 'paren', value:')'} if (char === ')') { tokens.push({ type: 'paren', value: ')' }); current++; continue; } // 匹配空白字符,匹配成功则跳过 // 使用 \s 匹配,包括空格、制表符、换页符、换行符、垂直制表符等 let WHITESPACE = /\s/; if (WHITESPACE.test(char)) { current++; continue; } // 匹配数字字符,使用 [0-9]:匹配 // 匹配成功则压入{type: 'number', value: value} // 如 (add 123 456) 中 123 和 456 为两个数值词法单元 let NUMBERS = /[0-9]/; if (NUMBERS.test(char)) { let value = ''; // 匹配连续数字,作为数值 while (NUMBERS.test(char)) { value += char; char = input[++current]; } tokens.push({ type: 'number', value }); continue; } // 匹配形双引号包围的字符串 // 匹配成功则压入 { type: 'string', value: value } // 如 (concat "foo" "bar") 中 "foo" 和 "bar" 为两个字符串词法单元 if (char === '"') { let value = ''; char = input[++current]; // 跳过左双引号 // 获取两个双引号之间所有字符 while (char !== '"') { value += char; char = input[++current]; } char = input[++current];// 跳过右双引号 tokens.push({ type: 'string', value }); continue; } // 匹配函数名,要求只含大小写字母,使用 [a-z] 匹配 i 模式 // 匹配成功则压入 { type: 'name', value: value } // 如 (add 2 4) 中 add 为一个名称词法单元 let LETTERS = /[a-z]/i; if (LETTERS.test(char)) { let value = ''; // 获取连续字符 while (LETTERS.test(char)) { value += char; char = input[++current]; } tokens.push({ type: 'name', value }); continue; } // 当遇到无法识别的字符,抛出错误提示,并退出 throw new TypeError('I dont know what this character is: ' + char); } // 词法分析器的最后返回词法单元数组 return tokens; }语法分析器语法分析器方法 parser 的主要任务:将词法分析器返回的词法单元数组,转换为能够描述语法成分及其关系的中间形式(抽象语法树 AST)。// 语法分析器 参数:词法单元数组tokens function parser(tokens) { let current = 0; // 设置当前解析的词法单元的索引,作为游标 // 递归遍历(因为函数调用允许嵌套),将词法单元转成 LISP 的 AST 节点 function walk() { // 获取当前索引下的词法单元 token let token = tokens[current]; // 数值类型词法单元 if (token.type === 'number') { current++; // 自增当前 current 值 // 生成一个 AST节点 'NumberLiteral',表示数值字面量 return { type: 'NumberLiteral', value: token.value, }; } // 字符串类型词法单元 if (token.type === 'string') { current++; // 生成一个 AST节点 'StringLiteral',表示字符串字面量 return { type: 'StringLiteral', value: token.value, }; } // 函数类型词法单元 if (token.type === 'paren' && token.value === '(') { // 跳过左括号,获取下一个词法单元作为函数名 token = tokens[++current]; let node = { type: 'CallExpression', name: token.value, params: [] }; // 再次自增 current 变量,获取参数词法单元 token = tokens[++current]; // 遍历每个词法单元,获取函数参数,直到出现右括号")" while ((token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')) { node.params.push(walk()); token = tokens[current]; } current++; // 跳过右括号 return node; } // 无法识别的字符,抛出错误提示 throw new TypeError(token.type); } // 初始化 AST 根节点 let ast = { type: 'Program', body: [], }; // 循环填充 ast.body while (current < tokens.length) { ast.body.push(walk()); } // 最后返回ast return ast; }3.4 转换阶段在转换阶段中,定义了转换器 transformer 函数,使用词法分析器返回的 LISP 的 AST 对象作为参数,将 AST 对象转换成一个新的 AST 对象。为了方便代码组织,我们定义一个遍历器 traverser 方法,用来处理每一个节点的操作。// 遍历器 参数:ast 和 visitor function traverser(ast, visitor) { // 定义方法 traverseArray // 用于遍历 AST节点数组,对数组中每个元素调用 traverseNode 方法。 function traverseArray(array, parent) { array.forEach(child => { traverseNode(child, parent); }); } // 定义方法 traverseNode // 用于处理每个 AST 节点,接受一个 node 和它的父节点 parent 作为参数 function traverseNode(node, parent) { // 获取 visitor 上对应方法的对象 let methods = visitor[node.type]; // 获取 visitor 的 enter 方法,处理操作当前 node if (methods && methods.enter) { methods.enter(node, parent); } switch (node.type) { // 根节点 case 'Program': traverseArray(node.body, node); break; // 函数调用 case 'CallExpression': traverseArray(node.params, node); break; // 数值和字符串,忽略 case 'NumberLiteral': case 'StringLiteral': break; // 当遇到无法识别的字符,抛出错误提示,并退出 default: throw new TypeError(node.type); } if (methods && methods.exit) { methods.exit(node, parent); } } // 首次执行,开始遍历 traverseNode(ast, null); }在看遍历器 traverser 方法时,建议结合下面介绍的转换器 transformer 方法阅读:// 转化器,参数:ast function transformer(ast) { // 创建 newAST,与之前 AST 类似,Program:作为新 AST 的根节点 let newAst = { type: 'Program', body: [], }; // 通过 _context 维护新旧 AST,注意 _context 是一个引用,从旧的 AST 到新的 AST。 ast._context = newAst.body; // 通过遍历器遍历 处理旧的 AST traverser(ast, { // 数值,直接原样插入新AST,类型名称 NumberLiteral NumberLiteral: { enter(node, parent) { parent._context.push({ type: 'NumberLiteral', value: node.value, }); }, }, // 字符串,直接原样插入新AST,类型名称 StringLiteral StringLiteral: { enter(node, parent) { parent._context.push({ type: 'StringLiteral', value: node.value, }); }, }, // 函数调用 CallExpression: { enter(node, parent) { // 创建不同的AST节点 let expression = { type: 'CallExpression', callee: { type: 'Identifier', name: node.name, }, arguments: [], }; // 函数调用有子类,建立节点对应关系,供子节点使用 node._context = expression.arguments; // 顶层函数调用算是语句,包装成特殊的AST节点 if (parent.type !== 'CallExpression') { expression = { type: 'ExpressionStatement', expression: expression, }; } parent._context.push(expression); }, } }); return newAst; }重要一点,这里通过 _context 引用来维护新旧 AST 对象,管理方便,避免污染旧 AST 对象。3.5 代码生成接下来到了最后一步,我们定义代码生成器 codeGenerator 方法,通过递归,将新的 AST 对象代码转换成 JavaScript 可执行代码字符串。// 代码生成器 参数:新 AST 对象 function codeGenerator(node) { switch (node.type) { // 遍历 body 属性中的节点,且递归调用 codeGenerator,按行输出结果 case 'Program': return node.body.map(codeGenerator) .join('\n'); // 表达式,处理表达式内容,并用分号结尾 case 'ExpressionStatement': return ( codeGenerator(node.expression) + ';' ); // 函数调用,添加左右括号,参数用逗号隔开 case 'CallExpression': return ( codeGenerator(node.callee) + '(' + node.arguments.map(codeGenerator) .join(', ') + ')' ); // 标识符,返回其 name case 'Identifier': return node.name; // 数值,返回其 value case 'NumberLiteral': return node.value; // 字符串,用双引号包裹再输出 case 'StringLiteral': return '"' + node.value + '"'; // 当遇到无法识别的字符,抛出错误提示,并退出 default: throw new TypeError(node.type); } }3.6 编译器测试截止上一步,我们完成简易编译器的代码开发。接下来通过前面原始需求的代码,测试编译器效果如何:const add = (a, b) => a + b; const subtract = (a, b) => a - b; const source = "(add 2 (subtract 4 2))"; const target = compiler(source); // "add(2, (subtract(4, 2));" const result = eval(target); // Ok result is 43.7 工作流程小结总结 The Super Tiny Compiler 编译器整个工作流程:1、input => tokenizer => tokens2、tokens => parser => ast3、ast => transformer => newAst4、newAst => generator => output其实多数编译器的工作流程都大致相同: 四、手写 Webpack 编译器根据之前介绍的 The Super Tiny Compiler编译器核心工作流程,再来手写 Webpack 的编译器,会让你有种众享丝滑的感觉~话说,有些面试官喜欢问这个呢。当然,手写一遍能让我们更了解 Webpack 的构建流程,这个章节我们简要介绍一下。4.1 Webpack 构建流程分析从启动构建到输出结果一系列过程:初始化参数解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。开始编译上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。确定入口从配置的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。编译模块递归中根据文件类型和 loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。完成模块编译并输出递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk 。输出完成输出所有的 chunk 到文件系统。注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩覆盖之前的结果。4.2 代码实现手写 Webpack 需要实现以下三个核心方法:createAssets : 收集和处理文件的代码;createGraph :根据入口文件,返回所有文件依赖图;bundle : 根据依赖图整个代码并输出;1. createAssetsfunction createAssets(filename){ const content = fs.readFileSync(filename, "utf-8"); // 根据文件名读取文件内容 // 将读取到的代码内容,转换为 AST const ast = parser.parse(content, { sourceType: "module" // 指定源码类型 }) const dependencies = []; // 用于收集文件依赖的路径 // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径 traverse(ast, { ImportDeclaration: ({node}) => { dependencies.push(node.source.value); } }); // 通过 AST 将 ES6 代码转换成 ES5 代码 const { code } = babel.transformFromAstSync(ast, null, { presets: ["@babel/preset-env"] }); let id = moduleId++; return { id, filename, code, dependencies } }2. createGraphfunction createGraph(entry) { const mainAsset = createAssets(entry); // 获取入口文件下的内容 const queue = [mainAsset]; for(const asset of queue){ const dirname = path.dirname(asset.filename); asset.mapping = {}; asset.dependencies.forEach(relativePath => { const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径 const child = createAssets(absolutePath); asset.mapping[relativePath] = child.id; queue.push(child); // 递归去遍历所有子节点的文件 }) } return queue; }3. bunldefunction bundle(graph) { let modules = ""; graph.forEach(item => { modules += ` ${item.id}: [ function (require, module, exports){ ${item.code} }, ${JSON.stringify(item.mapping)} ], ` }) return ` (function(modules){ function require(id){ const [fn, mapping] = modules[id]; function localRequire(relativePath){ return require(mapping[relativePath]); } const module = { exports: {} } fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) ` }五、总结本文从编译器概念和基本工作流程开始介绍,然后通过 The Super Tiny Compiler 译器源码,详细介绍核心工作流程实现,包括词法分析器、语法分析器、遍历器和转换器的基本实现,最后通过代码生成器,将各个阶段代码结合起来,实现了这个号称可能是有史以来最小的编译器。本文也简要介绍了手写 Webpack 的实现,需要读者自行完善和深入哟! 是不是觉得很神奇~当然通过本文学习,也仅仅是编译器相关知识的边山一脚,要学的知识还有非常多,不过好的开头,更能促进我们学习动力。加油!最后,文中介绍到的代码,我存放在 Github 上:[learning]the-super-tiny-compiler.js[writing]webpack-compiler.js六、参考资料《The Super Tiny Compiler》《有史以来最小的编译器源码解析》《Angular 2 JIT vs AOT》
最近看到掘金、前端公众号好多 ES2020 的文章,想说一句:放开我,我还学得动!先问大家一句,日常项目开发中你能离开 ES6 吗?一、前言对于前端同学来说,编译器可能适合神奇的魔盒🎁,表面普通,但常常给我们惊喜。编译器,顾名思义,用来编译,编译什么呢?当然是编译代码咯🌹。其实我们也经常接触到编译器的使用场景:React 中 JSX 转换成 JS 代码;通过 Babel 将 ES6 及以上规范的代码转换成 ES5 代码;通过各种 Loader 将 Less / Scss 代码转换成浏览器支持的 CSS 代码;将 TypeScript 转换为 JavaScript 代码。and so on...使用场景非常之多,我的双手都数不过来了。😄虽然现在社区已经有非常多工具能为我们完成上述工作,但了解一些编译原理是很有必要的。接下来进入本文主题:200行JS代码,带你实现代码编译器。二、编译器介绍2.1 程序运行方式现代程序主要有两种编译模式:静态编译和动态解释。推荐一篇文章《Angular 2 JIT vs AOT》介绍得非常详细。静态编译简称 AOT(Ahead-Of-Time)即 提前编译 ,静态编译的程序会在执行前,会使用指定编译器,将全部代码编译成机器码。(图片来自:segmentfault.com/a/119000000…)在 Angular 的 AOT 编译模式开发流程如下:使用 TypeScript 开发 Angular 应用运行 ngc 编译应用程序使用 Angular Compiler 编译模板,一般输出 TypeScript 代码运行 tsc 编译 TypeScript 代码使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等部署应用动态解释简称 JIT(Just-In-Time)即 即时编译 ,动态解释的程序会使用指定解释器,一边编译一边执行程序。(图片来自:segmentfault.com/a/119000000…)在 Angular 的 JIT 编译模式开发流程如下:使用 TypeScript 开发 Angular 应用运行 tsc 编译 TypeScript 代码使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等部署应用AOT vs JITAOT 编译流程:(图片来自:segmentfault.com/a/119000000…)JIT 编译流程:(图片来自:segmentfault.com/a/119000000…)特性AOTJIT编译平台(Server) 服务器(Browser) 浏览器编译时机Build (构建阶段)Runtime (运行时)包大小较小较大执行性能更好-启动时间更短-除此之外 AOT 还有以下优点:在客户端我们不需要导入体积庞大的 angular 编译器,这样可以减少我们 JS 脚本库的大小使用 AOT 编译后的应用,不再包含任何 HTML 片段,取而代之的是编译生成的 TypeScript 代码,这样的话 TypeScript 编译器就能提前发现错误。总而言之,采用 AOT 编译模式,我们的模板是类型安全的。2.2 现代编译器工作流程摘抄维基百科中对 编译器工作流程介绍:一个现代编译器的主要工作流程如下: 源代码(source code)→ 预处理器(preprocessor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标代码(object code)→ 链接器(linker)→ 可执行文件(executables),最后打包好的文件就可以给电脑去判读运行了。这里更强调了编译器的作用:将原始程序作为输入,翻译产生目标语言的等价程序。目前绝大多数现代编译器工作流程基本类似,包括三个核心阶段:解析(Parsing) :通过词法分析和语法分析,将原始代码字符串解析成抽象语法树(Abstract Syntax Tree);转换(Transformation):对抽象语法树进行转换处理操作;生成代码(Code Generation):将转换之后的 AST 对象生成目标语言代码字符串。三、编译器实现本文将通过 The Super Tiny Compiler 源码解读,学习如何实现一个轻量编译器,最终实现将下面原始代码字符串(Lisp 风格的函数调用)编译成 JavaScript 可执行的代码。Lisp 风格(编译前)JavaScript 风格(编译后)2 + 2(add 2 2)add(2, 2)4 - 2(subtract 4 2)subtract(4, 2)2 + (4 - 2)(add 2 (subtract 4 2))add(2, subtract(4, 2))话说 The Super Tiny Compiler 号称可能是有史以来最小的编译器,并且其作者 James Kyle 也是 Babel 活跃维护者之一。让我们开始吧~3.1 The Super Tiny Compiler 工作流程现在对照前面编译器的三个核心阶段,了解下 The Super Tiny Compiler 编译器核心工作流程:图中详细流程如下:执行入口函数,输入原始代码字符串作为参数;// 原始代码字符串 (add 2 (subtract 4 2))进入解析阶段(Parsing),原始代码字符串通过词法分析器(Tokenizer)转换为词法单元数组,然后再通过 语法分析器(Parser)将词法单元数组转换为抽象语法树(Abstract Syntax Tree 简称 AST),并返回;进入转换阶段(Transformation),将上一步生成的 AST 对象 导入转换器(Transformer),通过转换器中的遍历器(Traverser),将代码转换为我们所需的新的 AST 对象;进入代码生成阶段(Code Generation),将上一步返回的新 AST 对象通过代码生成器(CodeGenerator),转换成 JavaScript Code;代码编译结束,返回 JavaScript Code。上述流程看完后可能一脸懵逼,不过没事,请保持头脑清醒,先有个整个流程的印象,接下来我们开始阅读代码:3.2 入口方法首先定义一个入口方法 compiler ,接收原始代码字符串作为参数,返回最终 JavaScript Code:// 编译器入口方法 参数:原始代码字符串 input function compiler(input) { let tokens = tokenizer(input); let ast = parser(tokens); let newAst = transformer(ast); let output = codeGenerator(newAst); return output; }3.3 解析阶段在解析阶段中,我们定义词法分析器方法 tokenizer 和语法分析器方法 parser 然后分别实现:// 词法分析器 参数:原始代码字符串 input function tokenizer(input) {}; // 语法分析器 参数:词法单元数组tokens function parser(tokens) {};
本文使用的Webpack-Quickly-Starter快速搭建 Webpack4 本地学习环境。建议多阅读 Webpack 文档《Writing a Plugin》章节,学习开发简单插件。本文将带你一起开发你的第一个 Webpack 插件,从 Webpack 配置工程师,迈向 Webpack 开发工程师!做自己的轮子,让别人用去吧。完整代码存放在:github.com/pingan8787/…一、背景介绍本文灵感源自业务中的经验总结,不怕神一样的产品,只怕一根筋的开发。在项目打包遇到问题:“当项目托管到 CDN 平台,希望实现项目中的 index.js 不被缓存”。因为我们需要修改 index.js 中的内容,不想用户被缓存。思考一阵,有这么几种思路:在 CDN 平台中过滤该文件的缓存设置;查找 DOM 元素,修改该 script 标签的 src 值,并添加时时间戳;打包时动态创建 script 标签引入文件,并添加时时间戳。(聪明的你还有其他方法,欢迎讨论)思路分析:显然修改 CDN 设置的话,治标不治本;在模版文件中,添加 script 标签,执行获取 Webpack 自动添加的 script 标签并为其 src 值添加时间戳。但事实是还没等你修改完, js 文件已经加载完毕,所以放弃需要在 index.html 生成之前,修改 js 文件的路径,并添加时间戳。于是我准备使用第三种方式,在 index.html 生成之前完成下面修改:问题简单,实际还是想试试开发 Webpack Plugin。二、基础知识Webpack 使用阶段式的构建回调,开发者可以引入它们自己的行为到 Webpack 构建流程中。在开发之前,需要了解以下 Webpack 相关概念:2.1 Webpack 插件组成在自定义插件之前,我们需要了解,一个 Webpack 插件由哪些构成,下面摘抄文档:一个具名 JavaScript 函数;在它的原型上定义 apply 方法;指定一个触及到 Webpack 本身的事件钩子;操作 Webpack 内部的实例特定数据;在实现功能后调用 Webpack 提供的 callback。 2.2 Webpack 插件基本架构插件由一个构造函数实例化出来。构造函数定义 apply 方法,在安装插件时,apply 方法会被 Webpack compiler 调用一次。apply 方法可以接收一个 Webpack compiler 对象的引用,从而可以在回调函数中访问到 compiler 对象。官方文档提供一个简单的插件结构:class HelloWorldPlugin { apply(compiler) { compiler.hooks.done.tap('Hello World Plugin', ( stats /* 在 hook 被触及时,会将 stats 作为参数传入。 */ ) => { console.log('Hello World!'); }); } } module.exports = HelloWorldPlugin;使用插件:// webpack.config.js var HelloWorldPlugin = require('hello-world'); module.exports = { // ... 这里是其他配置 ... plugins: [new HelloWorldPlugin({ options: true })] };2.3 HtmlWebpackPlugin 介绍HtmlWebpackPlugin 简化了 HTML 文件的创建,以便为你的 Webpack 包提供服务。这对于在文件名中包含每次会随着编译而发生变化哈希的 webpack bundle 尤其有用。插件的基本作用概括:生成 HTML 文件。html-webapck-plugin 插件两个主要作用:为 HTML 文件引入外部资源(如 script / link )动态添加每次编译后的 hash,防止引用文件的缓存问题;动态创建 HTML 入口文件,如单页应用的 index.html 文件。html-webapck-plugin 插件原理介绍:读取 Webpack 中 entry 配置的相关入口 chunk 和 extract-text-webpack-plugin 插件抽取的 CSS 样式;将样式插入到插件提供的 template 或 templateContent 配置指定的模版文件中;插入方式是:通过 link 标签引入样式,通过 script 标签引入脚本文件;三、开发流程本文开发的 自动添加时间戳引用脚本文件(SetScriptTimestampPlugin) 插件实现的原理:通过 HtmlWebpackPlugin 生成 HTML 文件前,将模版文件预留位置替换成脚本,脚本中执行自动添加时间戳来引用脚本文件。3.1 插件运行机制3.2 初始化插件文件新建 SetScriptTimestampPlugin.js 文件,并参考官方文档中插件的基本结构,初始化插件代码:// SetScriptTimestampPlugin.js class SetScriptTimestampPlugin { apply(compiler) { compiler.hooks.done.tap('SetScriptTimestampPlugin', (compilation, callback) => { console.log('SetScriptTimestampPlugin!'); }); } } module.exports = SetScriptTimestampPlugin;apply 方法为插件原型方法,接收 compiler 作为参数。3.3 选择插件触发时机选择插件触发时机,其实是选择插件触发的 compiler 钩子(即何时触发插件)。Webpack 提供钩子有很多,这里简单介绍几个,完整具体可参考文档《Compiler Hooks》:entryOption : 在 webpack 选项中的 entry 配置项 处理过之后,执行插件。afterPlugins : 设置完初始插件之后,执行插件。compilation : 编译创建之后,生成文件之前,执行插件。。emit : 生成资源到 output 目录之前。done : 编译完成。我们插件应该是要在 HTML 输出之前,动态添加 script 标签,所以我们选择钩入 compilation 阶段,代码修改:// SetScriptTimestampPlugin.js class SetScriptTimestampPlugin { apply(compiler) { - compiler.hooks.done.tap('SetScriptTimestampPlugin', + compiler.hooks.compilation.tap('SetScriptTimestampPlugin', (compilation, callback) => { console.log('SetScriptTimestampPlugin!'); }); } } module.exports = SetScriptTimestampPlugin;在 compiler.hooks 下指定事件钩子函数,便会触发钩子时,执行回调函数。Webpack 提供三种触发钩子的方法:tap :以同步方式触发钩子;tapAsync :以异步方式触发钩子;tapPromise :以异步方式触发钩子,返回 Promise;这三种方式能选择的钩子方法也不同,由于 compilation 是 SyncHook 同步钩子,所以采用 tap 触发方式。tap 方法接收两个参数:插件名称和回调函数。3.4 添加插件替换入口我们原理上是将模版文件中,指定替换入口,再替换成需要执行的脚本。所以我们在模版文件 template.html 中添加 <!--SetScriptTimestampPlugin inset script--> 作为标识替换入口:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Webpack 插件开发入门</title> </head> <body> <!-- other code --> <!--SetScriptTimestampPlugin inset script--> </body> </html>3.5 编写插件逻辑到这一步,才开始编写插件的逻辑。从上一步中,我们知道在 tap 第二个参数是个回调函数,并且这个回调函数有两个参数: compilation 和 callback 。compilation 继承于compiler,包含 compiler 所有内容(也有 Webpack 的 options),而且也有 plugin 函数接入任务点。// SetScriptTimestampPlugin.js class SetScriptTimestampPlugin { apply(compiler) { compiler.hooks.compilation.tap('SetScriptTimestampPlugin', (compilation, callback) => { // 插件逻辑 调用compilation提供的plugin方法 compilation.plugin( "html-webpack-plugin-before-html-processing", function(htmlPluginData, callback) { // 读取并修改 script 上 src 列表 let jsScr = htmlPluginData.assets.js[0]; htmlPluginData.assets.js = []; let result = ` <script> let scriptDOM = document.createElement("script"); let jsScr = "./${jsScr}"; scriptDOM.src = jsScr + "?" + new Date().getTime(); document.body.appendChild(scriptDOM) </script> `; let resultHTML = htmlPluginData.html.replace( "<!--SetScriptTimestampPlugin inset script-->", result ); // 返回修改后的结果 htmlPluginData.html = resultHTML; } ); } ); } } module.exports = SetScriptTimestampPlugin;在上面插件逻辑中,具体做了这些事:执行 compilation.plugin 方法,并传入两个参数:插件事件和回调方法。所谓“插件事件”即插件所提供的一些事件,用于监听插件状态,这里列举几个 html-webpack-plugin 提供的事件(完整可查看《html-webpack-plugin》):Async:html-webpack-plugin-before-html-generationhtml-webpack-plugin-before-html-processinghtml-webpack-plugin-alter-asset-tagsSync:html-webpack-plugin-alter-chunks获取脚本文件名称列表并清空。在回调方法中,通过 htmlPluginData.assets.js 获取需要通过 script 引入的脚本文件名称列表,拷贝一份,并清空原有列表。编写替换逻辑。替换逻辑即:动态创建一个 script 标签,将其 src 值设置为上一步读取到的脚本文件名,并在后面拼接 时间戳 作为参数。插入替换逻辑。通过 htmlPluginData.html 可以获取到模版文件的字符串输出,我们只需要将模版字符串中替换入口 <!--SetScriptTimestampPlugin inset script--> 替换成我们上一步编写的替换逻辑即可。返回HTML文件。最后将修改后的 HTML 字符串,赋值给原来的 htmlPluginData.html 达到修改效果。3.5 使用插件自定义插件使用方式,与其他插件一致,在 plugins 数组中实例化:// webpack.config.js const SetScriptTimestampPlugin = require("./SetScriptTimestampPlugin.js"); module.exports = { // ... 省略其他配置 plugins: [ // ... 省略其他插件 new SetScriptTimestampPlugin() ] }到这一步,我们已经实现需求“当项目托管到 CDN 平台,希望实现项目中的 index.js 不被缓存”。四、案例拓展这里以之前 SetScriptTimestampPlugin 插件为例子,继续拓展。4.1 读取插件配置参数每个插件本质是一个类,跟一个类实例化相同,可以在实例化时传入配置参数,在构造函数中操作:// SetScriptTimestampPlugin.js class SetScriptTimestampPlugin { constructor(options) { this.options = options; } apply(compiler) { console.log(this.options.filename); // "index.js" // ... 省略其他代码 } } module.exports = SetScriptTimestampPlugin;使用时:// webpack.config.js const SetScriptTimestampPlugin = require("./SetScriptTimestampPlugin.js"); module.exports = { // ... 省略其他配置 plugins: [ // ... 省略其他插件 new SetScriptTimestampPlugin({ filename: "index.js" }) ] }4.2 添加多脚本文件的时间戳如果我们此时需要同时修改多个脚本文件的时间戳,也只需要将参数类型和执行脚本做下调整。具体修改脚本,这里不具体展开,篇幅有限,可以自行思考实现咯~这里展示使用插件时的参数:// webpack.config.js const SetScriptTimestampPlugin = require("./SetScriptTimestampPlugin.js"); module.exports = { // ... 省略其他配置 plugins: [ // ... 省略其他插件 new SetScriptTimestampPlugin({ filename: ["index.js", "boundle.js", "pingan.js"] }) ] }生成结果:<script src="./index.js?1582425467655"></script> <script src="./boundle.js?1582425467655"></script> <script src="./pingan.js?1582425467655"></script>五、总结本文通用自定义 Webpack 插件来实现日常一些比较棘手的需求。主要为大家介绍了 Webpack 插件的基本组成和简单架构,也介绍了 HtmlWebpackPlugin 插件。并通过这些基础知识,完成了一个 HTML 文本替换插件,最后通过两个场景来拓展插件使用范围。最后,关于 Webpack 插件开发,还有更多知识可以学习,建议多看看官方文档《Writing a Plugin》进行学习。本文纯属个人经验总结,如有异议,欢迎指点。参考文档《Writing a Plugin》《HtmlWebpackPlugin - Webpack》《扩展 HtmlwebpackPlugin 插入自定义的脚本》
在前端开发世界中,JavaScript 和 HTML 之间往往通过 事件 来实现交互。其中多数为内置事件,本文主要介绍 JS自定义事件概念和实现方式,并结合案例详细分析自定义事件的原理、功能、应用及注意事项。📚一、什么是自定义事件在日常开发中,我们习惯监听页面许多事件,诸如:点击事件( click )、鼠标移动事件( mousemove )、元素失去焦点事件( blur )等等。事件本质是一种通信方式,是一种消息,只有在多对象多模块时,才有可能需要使用事件进行通信。在多模块化开发时,可以使用自定义事件进行模块间通信。当某些基础事件无法满足我们业务,就可以尝试 自定义事件来解决。📚二、实现方式介绍目前实现自定义事件的两种主要方式是 JS 原生的 Event() 构造函数和 CustomEvent() 构造函数来创建。1. Event()Event() 构造函数, 创建一个新的事件对象 Event。1.1 语法let myEvent = new Event(typeArg, eventInit);1.2 参数typeArg : DOMString 类型,表示创建事件的名称;eventInit :可选配置项,包括:字段名称说明是否可选类型默认值bubbles表示该事件是否冒泡。可选Boolean falsecancelable表示该事件能否被取消。可选Boolean falsecomposed指示事件是否会在影子DOM根节点之外触发侦听器。 可选Boolean false1.3 演示示例// 创建一个支持冒泡且不能被取消的 pingan 事件 let myEvent = new Event("pingan", {"bubbles":true, "cancelable":false}); document.dispatchEvent(myEvent); // 事件可以在任何元素触发,不仅仅是document testDOM.dispatchEvent(myEvent);1.4 兼容性图片来源:caniuse.com/2. CustomEvent()CustomEvent() 构造函数, 创建一个新的事件对象 CustomEvent。2.1 语法let myEvent = new CustomEvent(typeArg, eventInit);2.2 参数typeArg : DOMString 类型,表示创建事件的名称;eventInit :可选配置项,包括:字段名称说明是否可选类型默认值detail表示该事件中需要被传递的数据,在 EventListener 获取。可选Any nullbubbles表示该事件是否冒泡。可选Boolean falsecancelable表示该事件能否被取消。可选Boolean false2.3 演示示例// 创建事件 let myEvent = new CustomEvent("pingan", { detail: { name: "wangpingan" } }); // 添加适当的事件监听器 window.addEventListener("pingan", e => { alert(`pingan事件触发,是 ${e.detail.name} 触发。`); }); document.getElementById("leo2").addEventListener( "click", function () { // 派发事件 window.dispatchEvent(myEvent); } )我们也可以给自定义事件添加属性:myEvent.age = 18;2.4 兼容性图片来源:caniuse.com/2.5 IE8 兼容分发事件时,需要使用 dispatchEvent 事件触发,它在 IE8 及以下版本中需要进行使用 fireEvent 方法兼容:if(window.dispatchEvent) { window.dispatchEvent(myEvent); } else { window.fireEvent(myEvent); }3. Event() 与 CustomEvent() 区别从两者支持的参数中,可以看出:Event() 适合创建简单的自定义事件,而 CustomEvent() 支持参数传递的自定义事件,它支持 detail 参数,作为事件中需要被传递的数据,并在 EventListener 获取。注意:当一个事件触发时,若相应的元素及其上级元素没有进行事件监听,则不会有回调操作执行。 当需要对于子元素进行监听,可以在其父元素进行事件托管,让事件在事件冒泡阶段被监听器捕获并执行。此时可以使用 event.target 获取到具体触发事件的元素。📚三、使用场景事件本质是一种消息,事件模式本质上是观察者模式的实现,即能用观察者模式的地方,自然也能用事件模式。1.场景介绍比如这两种场景:场景1:单个目标对象发生改变,需要通知多个观察者一同改变。如:当微博列表中点击“关注”,此时会同时发生很多事:推荐更多类似微博,个人关注数增加...场景2:解耦多模块开协作。如:小王负责A模块开发,小陈负责B模块开发,模块B需要模块A正常运行之后才能执行。2. 代码实现2.1 场景1实现场景1:单个目标对象发生改变,需要通知多个观察者一同改变。本例子模拟三个页面进行演示:1.微博列表页(Weibo.js)2.粉丝列表页(User.js)3.微博首页(Home.js)在**微博列表页(Weibo.js)**中,我们导入其他两个页面,并且监听【关注微博】按钮的点击事件,在回调事件中,创建一个自定义事件 focusUser,并在 document 上使用 dispatchEvent 方法派发自定义事件。// Weibo.js import UserModule from "./User.js"; import HomeModule from "./Home.js"; const eventButton = document.getElementById("eventButton"); eventButton.addEventListener("click", event => { const focusUser = new Event("focusUser"); document.dispatchEvent(focusUser); })接下来两个页面实现的代码基本一致,这里为了方便观察,设置了两者不同输出日志。// User.js const eventButton = document.getElementById("eventButton"); document.addEventListener("focusUser", event => { console.log("【粉丝列表页】监听到自定义事件触发,event:",event); }) // Home.js const eventButton = document.getElementById("eventButton"); document.addEventListener("focusUser", event => { console.log("【微博首页】监听到自定义事件触发,event:",event); })点击【关注微博】按钮后,看到控制台输出如下日志信息:最终实现了,在 **微博列表页(Weibo.js)**组件负责派发事件,其他组价负责监听事件,这样三个组件之间耦合度非常低,完全不用关系对方,互相不影响。其实这也是实现了观察者模式。2.2 场景2实现场景2:解耦多模块开协作。举个更直观的例子,当微博需要加入【一键三连】新功能,需要产品原型和UI设计完后,程序员才能开发。本例子模拟四个模块:1.流程控制(Index.js)2.产品设计(Production.js)3.UI设计(Design.js)4.程序员开发(Develop.js)在流程控制(Index.js)模块中,我们需要将其他三个流程的模块都导入进来,然后监听【开始任务】按钮的点击事件,在回调事件中,创建一个自定义事件 startTask,并在 document 上使用 dispatchEvent 方法派发自定义事件。// Index.js import ProductionModule from "./Production.js"; import DesignModule from "./Design.js"; import DevelopModule from "./Develop.js"; const start = document.getElementById("start"); start.addEventListener("click", event => { console.log("开始执行任务") const startTask = new Event("startTask"); document.dispatchEvent(startTask); })在 Production 产品设计模块中,监听任务开始事件 startTask 后,模拟1秒后原型设计完成,并派发一个新的事件 productionSuccess ,开始接下来的UI稿设计。// Production.js document.addEventListener("startTask", () => { console.log("产品开始设计..."); setTimeout(() => { console.log("产品原型设计完成"); console.log("--------------"); document.dispatchEvent(new Event("productionSuccess")); }, 1000); });在UI稿设计和程序开发模块,其实也类似,代码实现:// Dedign.js document.addEventListener("productionSuccess", () => { console.log("UI稿开始设计..."); setTimeout(() => { console.log("UI稿设计完成"); console.log("--------------"); document.dispatchEvent(new Event("designSuccess")); }, 1000); }); // Production.js document.addEventListener("designSuccess", function (e) { console.log("开始开发功能..."); setTimeout(function () { console.log("【一键三连】开发完成"); }, 2000) });开发完成后,我们点击【开始任务】按钮后,看到控制台输出如下日志信息:最终实现了在 流程控制(Index.js)模块负责派发事件,其他组件负责监听事件,按流程完成其他任务。可以看出,原型设计、UI稿设计和程序开发任务,互不影响,易于任务拓展。📚四、总结本文详细介绍 JS自定义事件概念和实现方式,并结合两个实际场景进行代码演示。细心的小伙伴会发现,这两个实际场景都是用 Event() 构造函数实现,当然也是可以使用 CustomEvent 构造函数来代替。另外本文也详细介绍两种实现方式,包括其区别和兼容性。最后也希望大家能在实际开发中,多思考代码解耦,适当使用自定义事件来提高代码质量。📚五、参考文章《javascript自定义事件功能与用法实例分析》《Event - MDN》《CustomEvent - MDN》
本文首发在我的【个人博客】更多丰富的前端学习资料,可以查看我的 Github: 《Leo-JavaScript》,内容涵盖数据结构与算法、HTTP、Hybrid、面试题、React、Angular、TypeScript和Webpack等等。 点个 Star 不迷路~欢迎阅读《前端知乎系列》:《【前端知乎】443- ArrayBuffer 与 Blob 对象详解》File 对象、FileList 对象与 FileReader 对象大家或许不太陌生,常见于文件上传下载操作处理(如处理图片上传预览,读取文件内容,监控文件上传进度等问题)。那么本文将与大家深入介绍两者。一、File 对象1. 概念介绍File 对象提供有关文件的信息,并允许网页中的 JavaScript 读写文件。最常见的使用场合是表单的文件上传控件,用户在一个 <input type="file"> 元素上选择文件后,浏览器会生成一个数组,里面是每一个用户选中的文件,它们都是 File 实例对象。另外值得提到一点:File 对象是一种特殊 Blob 对象,并且可以用在任意的 Blob 对象的 context 中。比如说, FileReader, URL.createObjectURL(), createImageBitmap(), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。// HTML 代码如下 // <input id="fileItem" type="file"> const file = document.getElementById('fileItem').files[0]; file instanceof File // true2. 对象使用浏览器原生提供一个 File() 构造函数,用来生成 File 实例对象。const myFile = new File(bits, name[, options]);参数:bits:一个数组,表示文件的内容。成员可以是 ArrayBuffer,ArrayBufferView,Blob,或者 DOMString对象的 Array,或者任何这些对象的组合。通过这个参数,也可以实现 ArrayBuffer,ArrayBufferView,Blob 转换为 File 对象。name:字符串,表示文件名或文件路径。options:配置对象,设置实例的属性。该参数可选。可选值有如下两种:type: DOMString,表示将要放到文件中的内容的 MIME 类型。默认值为 "" 。 lastModified: 数值,表示文件最后修改时间的 Unix 时间戳(毫秒)。默认值为 Date.now()。示例:const myFile = new File(['leo1', 'leo2'], 'leo.txt', {type: 'text/plain'});根据已有的 blob 对象创建 File 对象:const myFile = new File([blob], 'leo.png', {type: 'image/png'});3. 实例属性和方法3.1 实例属性实例有以下几个属性:File.lastModified:最后修改时间。只读自 UNIX 时间起始值(1970年1月1日 00:00:00 UTC)以来的毫秒数File.name:文件名或文件路径。只读出于安全考虑,返回值不包含文件路径 。File.size:文件大小(单位字节)。只读File.type:文件的 MIME 类型。只读// HTML 代码如下 // <input id="fileItem" type="file"> const myFile = document.getElementById('fileItem') myFile.addEventListener('change', function(e){ const file = this.files[0]; console.log(file.name); console.log(file.size); console.log(file.lastModified); console.log(file.lastModifiedDate); });3.2 实例方法File 对象没有定义任何方法,但是它从 Blob 接口继承了以下方法:Blob.slice([start[, end[, contentType]]])返回一个新的 Blob 对象,它包含有源 Blob 对象中指定范围内的数据。4. 兼容性二、FileList 对象1. 概念介绍FileList 对象是一个类数组对象,每个成员都是一个 File 实例,主要出现在两种场合:通过 <input type="file"> 控件的 files 属性,返回一个 FileList 实例。另外,当 input 元素拥有 multiple 属性,则可以用它来选择多个文件。通过拖放文件,查看 DataTransfer.files 属性,返回一个 FileList 实例。// HTML 代码如下 // <input id="fileItem" type="file"> const files = document.getElementById('fileItem').files; files instanceof FileList // true const firstFile = files[0];2. 对象使用所有 type 属性为 file 的 <input> 元素都有一个 files 属性,用来存储用户所选择的文件. 例如:3. 实例属性和方法3.1 实例属性实例只有一个属性:FileList.length:返回列表中的文件数量。只读3.2 实例方法实例只有一个方法:FileList.item():用来返回指定位置的实例,从 0 开始。由于 FileList 实例是个类数组对象,可以直接用方括号运算符,即myFileList[0] 等同于 myFileList.item(0) ,所以一般用不到 item()方法。4. 兼容性5. 实例选择多个文件,并获取每个文件信息:// HTML 代码如下 // <input id="myfiles" multiple type="file"> const myFile = document.querySelector("#myfiles"); myFile.addEventListener('change', function(e){ let files = this.files; let fileLength = files.length; let i = 0; while ( i < fileLength) { let file = files[i]; console.log(file.name); i++; } });三、FileReader 对象1. 概念介绍FileReader 对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。简单理解,就是用于读取 File 对象或 Blob 对象所包含的文件内容。2. 对象使用浏览器原生提供一个 FileReader 构造函数,用来生成 FileReader 实例。const reader = new FileReader();3. 实例属性和方法FileReader 对象拥有的属性和方法较多。3.1 实例属性FileReader.error : 表示在读取文件时发生的错误。只读FileReader.readyState : 整数,表示读取文件时的当前状态。只读共有三种状态:0 : EMPTY,表示尚未加载任何数据; 1 : LOADING,表示数据正在加载; 2 : DONE,表示加载完成;FileReader.result 读取完成后的文件内容。只读仅在读取操作完成后才有效,返回的数据格式取决于使用哪个方法来启动读取操作。3.2 事件处理FileReader.onabort : 处理abort事件。该事件在读取操作被中断时触发。FileReader.onerror : 处理error事件。该事件在读取操作发生错误时触发。FileReader.onload : 处理load事件。该事件在读取操作完成时触发。FileReader.onloadstart : 处理loadstart事件。该事件在读取操作开始时触发。FileReader.onloadend : 处理loadend事件。该事件在读取操作结束时(要么成功,要么失败)触发。FileReader.onprogress : 处理progress事件。该事件在读取Blob时触发。3.3 实例方法FileReader.abort():终止读取操作,readyState 属性将变成2。FileReader.readAsArrayBuffer():以 ArrayBuffer 的格式读取文件,读取完成后 result 属性将返回一个 ArrayBuffer 实例。FileReader.readAsBinaryString():读取完成后, result 属性将返回原始的二进制字符串。FileReader.readAsDataURL():读取完成后, result 属性将返回一个 Data URL 格式(Base64 编码)的字符串,代表文件内容。对于图片文件,这个字符串可以用于<img>元素的 src 属性。注意,这个字符串不能直接进行 Base64 解码,必须把前缀 data:*/*;base64 ,从字符串里删除以后,再进行解码。FileReader.readAsText():读取完成后, result 属性将返回文件内容的文本字符串。该方法的第一个参数是代表文件的 Blob 实例,第二个参数是可选的,表示文本编码,默认为 UTF-8。4. 兼容性5. 实例这里举一个图片预览的实例:/* HTML 代码如下 <input type="file" onchange="previewFile()"> <img src="" height="200"> */ function previewFile() { let preview = document.querySelector('img'); let file = document.querySelector('input[type=file]').files[0]; let reader = new FileReader(); reader.addEventListener('load', function () { preview.src = reader.result; }, false); if (file) { reader.readAsDataURL(file); } }四、参考资料《File 对象,FileList 对象,FileReader 对象》MDN
本文首发在我的【个人博客】更多丰富的前端学习资料,可以查看我的 Github: 《Leo-JavaScript》,内容涵盖数据结构与算法、HTTP、Hybrid、面试题、React、Angular、TypeScript和Webpack等等。点个 Star 不迷路~ArrayBuffer 对象与 Blob 对象大家或许不太陌生,常见于文件上传操作处理(如处理图片上传预览等问题)。那么本文将与大家深入介绍两者。一、ArrayBuffer 对象ArrayBuffer 对象是 ES6 才纳入正式 ECMAScript 规范,是 JavaScript 操作二进制数据的一个接口。ArrayBuffer 对象是以数组的语法处理二进制数据,也称二进制数组。介绍 ArrayBuffer 对象还需介绍 TypedArray 视图和 DataView 视图,本文不具体介绍,详细可以查看阮一峰老师《ECMAScript 6 入门 ArrayBuffer》 章节。1. 概念介绍ArrayBuffer 对象代表储存二进制数据的一段内存,它不能直接读写,只能通过视图(TypedArray视图和DataView视图)来读写,视图的作用是以指定格式解读二进制数据。关于 TypedArray 视图和 DataView 视图 ,可以查看阮一峰老师《ECMAScript 6 入门 ArrayBuffer》 章节的介绍。2. 对象使用浏览器原生提供 ArrayBuffer() 构造函数,用来生成实例。参数:整数,表示二进制数据占用的字节长度。返回值:一个指定大小的 ArrayBuffer 对象,其内容被初始化为 0。const buffer = new ArrayBuffer(32);上面代码表示实例对象 buffer 占用 32 个字节。3. 实例属性和方法ArrayBuffer 对象有实例属性 byteLength ,表示当前实例占用的内存字节长度(单位字节),一单创建就不可变更(只读):const buffer = new ArrayBuffer(32); buffer.byteLength; // 32ArrayBuffer 对象有实例方法 slice(),用来复制一部分内存。参数如下:start,整数类型,表示开始复制的位置。默认从 0 开始。end,整数类型,表示结束复制的位置(不包括结束的位置)。如果省略,则表示复制到结束。const buffer = new ArrayBuffer(32); const buffer2 = buffer.slice(0);4. 兼容性图片来自 MDN二、Blob 对象1. 概念介绍Blob 全称:Binary Large Object (二进制大型对象)。Blob 对象表示一个二进制文件的数据内容,通常用来读写文件,比如一个图片文件的内容就可以通过 Blob 对象读写。与 ArrayBuffer 区别:Blob 用于操作二进制文件ArrayBuffer 用于操作内存2. 对象使用浏览器原生提供 Blob() 构造函数,用来生成实例。Blob 的内容由参数数组中给出的值的串联组成。const leoBlob = new Blob(array [, options]);参数:array,必填,成员是字符串或二进制对象,表示新生成的Blob实例对象的内容;成员可以是一个由 ArrayBuffer , ArrayBufferView , Blob , DOMString 等对象构成的 Array ,或者其他类似对象的混合体,它将会被放进 Blob。DOMStrings会被编码为UTF-8。options,可选,是一个配置对象,这里介绍常用的属性 type,表示数据的 MIME 类型,默认空字符串;options 目前可能有两个属性: type 和 endings。endings 用于指定包含行结束符 \n 的字符串如何被写入,默认值 transparent。它只有这两个值:native (代表行结束符会被更改为适合宿主操作系统文件系统的换行符)和 transparent (代表会保持blob中保存的结束符不变)。使用案例:const leoHtmlFragment = ['<a id="a"><b id="b">hey leo!</b></a>']; // 一个包含 DOMString 的数组 const leoBlob = new Blob(leoHtmlFragment, {type : 'text/html'}); // 得到 blob该代码中,实例对象 leoBlob 包含的是字符串。生成实例时,指定数据类型为 text/html。还可以使用 Blob 保存 JSON 数据:const obj = { hello: 'leo' }; const blob = new Blob([ JSON.stringify(obj) ], {type : 'application/json'});3. 实例属性和方法Blob 具有两个实例属性:size:文件的大小,单位为字节。type:文件的 MIME 类型。如果类型无法确定,则返回空字符串。const leoHtmlFragment = ['<a id="a"><b id="b">hey leo!</b></a>']; // 一个包含 DOMString 的数组 const leoBlob = new Blob(leoHtmlFragment, {type : 'text/html'}); // 得到 blob leoBlob.size; // 38 leoBlob.type; // "text/html"Blob 实例方法:clice:方法用于创建一个包含源 Blob 的指定字节范围内的数据的新 Blob 对象。const newBlob = oldBlob.slice([start [, end [, contentType]]])包含三个参数:start,可选,起始的字节位置,默认 0;end,可选,结束的字节位置,默认 size 属性的值,不包含该位置;contentType,可选,新实例的数据类型(默认为空字符串);4. 兼容性图片来自 MDN5. 实际案例5.1 获取文件信息文件选择器 <input type="file"> 用来让用户选取文件。出于安全考虑,浏览器不允许脚本自行设置这个控件的 value 属性,即文件必须是用户手动选取的,不能是脚本指定的。一旦用户选好了文件,脚本就可以读取这个文件。文件选择器返回一个 FileList 对象,该对象是个类数组对象,每个成员都是一个 File 实例对象。File 实例对象是一个特殊的 Blob 实例,增加了 name 和 lastModifiedDate 属性。也包括拖放 API 的 dataTransfer.files 返回的也是一个 FileList 对象,成员也是 File 实例对象。// HTML 代码如下 // <input type="file" accept="image/*" multiple onchange="fileinfo(this.files)"/> function fileinfo(files) { for (let i = 0; i < files.length; i++) { let f = files[i]; console.log( f.name, // 文件名,不含路径 f.size, // 文件大小,Blob 实例属性 f.type, // 文件类型,Blob 实例属性 f.lastModifiedDate // 文件的最后修改时间 ); } }5.2 下载文件在 AJAX 请求中,指定 responseType 属性为 blob ,皆可以下下载一个 Blob 对象。function getBlob(url, callback) { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.responseType = 'blob'; xhr.onload = function () { callback(xhr.response); } xhr.send(null); }然后,xhr.response 拿到的就是一个 Blob 对象。5.3 生成 URL浏览器允许使用 URL.createObjectURL() 方法,针对 Blob 对象生成一个临时URL,以便于某些 API 使用。如作为图片预览的 URL。这个 URL 以 blob:// 开头,表明对应一个 Blob 对象,协议头后面是一个识别符,用来唯一对应内存里面的 Blob 对象。这一点与 data://URL(URL 包含实际数据)和 file://URL(本地文件系统里面的文件)都不一样。const droptarget = document.getElementById('droptarget'); droptarget.ondrop = function (e) { const files = e.dataTransfer.files; for (let i = 0; i < files.length; i++) { let type = files[i].type; if (type.substring(0,6) !== 'image/') continue; let img = document.createElement('img'); img.src = URL.createObjectURL(files[i]); img.onload = function () { this.width = 100; document.body.appendChild(this); URL.revokeObjectURL(this.src); } } }代码中,通过为拖放的图片文件生成一个 URL,作为预览的缩略图。浏览器处理 Blob URL 就跟普通的 URL 一样,如果 Blob 对象不存在,返回404状态码;如果跨域请求,返回403状态码。Blob URL 只对 GET 请求有效,如果请求成功,返回200状态码。由于 Blob URL 就是普通 URL,因此可以下载。5.4 读取文件取得 Blob 对象以后,可以通过 FileReader 对象,读取 Blob 对象的内容,即文件内容。FileReader 对象提供四个方法。将 Blob 对象作为参数传入,然后以指定的格式返回。FileReader.readAsText():返回文本,需要指定文本编码,默认为 UTF-8。FileReader.readAsArrayBuffer():返回 ArrayBuffer 对象。FileReader.readAsDataURL():返回 Data URL。FileReader.readAsBinaryString():返回原始的二进制字符串。下面是 FileReader.readAsText() 方法的例子,用来读取文本文件:// HTML 代码如下 // <input type='file' onchange='readfile(this.files[0])'></input> // <pre id='output'></pre> function readfile(f) { let reader = new FileReader(); reader.readAsText(f); reader.onload = function () { let text = reader.result; let out = document.getElementById('output'); out.innerHTML = ''; out.appendChild(document.createTextNode(text)); } reader.onerror = function(e) { console.log('Error', e); }; }下面是 FileReader.readAsArrayBuffer() 方法的例子,用于读取二进制文件:// HTML 代码如下 // <input type="file" onchange="typefile(this.files[0])"></input> function typefile(file) { // 文件开头的四个字节,生成一个 Blob 对象 let slice = file.slice(0, 4); let reader = new FileReader(); // 读取这四个字节 reader.readAsArrayBuffer(slice); reader.onload = function (e) { let buffer = reader.result; // 将这四个字节的内容,视作一个32位整数 let view = new DataView(buffer); let magic = view.getUint32(0, false); // 根据文件的前四个字节,判断它的类型 switch(magic) { case 0x89504E47: file.verified_type = 'image/png'; break; case 0x47494638: file.verified_type = 'image/gif'; break; case 0x25504446: file.verified_type = 'application/pdf'; break; case 0x504b0304: file.verified_type = 'application/zip'; break; } console.log(file.name, file.verified_type); }; }三、参考资料《ArrayBuffer 对象,Blob 对象》《ECMAScript 6 入门 ArrayBuffer》
一、概念介绍1. REST 概念REST:(Representational State Transfer)即表现层状态转换,定义了资源的通用访问格式,是一种网络应用程序的设计风格和开发方式。在概念中,需要理解以下几个名称:资源(Resource)即服务器上获取到的东西任何资源,一条用户记录,一个用户的密码,一张图片等等都是。资源的表述(Representation)即资源格式,是 HTML、XML、JSON、纯文本、图片等等,可以用各种各样的格式来表述你获取到的资源。状态转移(State Transfer)即URL定位资源,用 HTTP 动词(GET,POST,DELETE,DETC)描述操作。操作是动词,资源是名词。统一接口(Uniform Interface)即通过统一的接口对资源进行操作。2. REST 特点REST 通常基于使用 HTTP , URI ,和 XML 以及 HTML 这些现有的广泛流行的协议和标准,每一种 URI 代表一种资源。REST 通常使用 JSON 数据格式。REST 基本架构的四个方法:GET - 用于获取数据PUT - 用于更新或添加数据DELETE - 用于删除数据POST - 用于添加数据下面会通过一个场景介绍。3. REST 优点可更高效利用缓存来提高响应速度通讯本身的无状态性可以让不同的服务器的处理一系列请求中的不同请求,提高服务器的扩展性浏览器即可作为客户端,简化软件需求相对于其他叠加在HTTP协议之上的机制,REST的软件依赖性更小不需要额外的资源发现机制在软件技术演进中的长期的兼容性更好二、实例介绍REST 定义了资源的通用访问格式,接下来一个消费者为实例,介绍 RESTful API 定义:获取所有 usersGET /api/users获取指定 id 的 usersGET /api/users/100新建一条 users 记录POST /api/users更新一条 users 记录PUT /api/users/100删除一条 users 记录DELETE /api/users/100获取一个 users 的所有消费账单GET /api/users/100/bill获取一个 user 指定时间的消费账单GET /api/users/100/bill?from=201910&to=201911以上其中 RESTful 风格 API 几乎包含常见业务情况。三、Nodejs 实现 RESTful API1. 初始化 mock 数据本案例使用 mock 数据来演示,如下:{ "user1" : { "name" : "leo", "password" : "123456", "profession" : "teacher", "id": 1 }, "user2" : { "name" : "pingan8787", "password" : "654321", "profession" : "librarian", "id": 2 }, "user3" : { "name" : "robin", "password" : "888888", "profession" : "clerk", "id": 3 } }我们将实现以下 RESTful API :2. 获取用户列表这一步我们会创建 RESTful API 中的 /users,使用 GET 来读取用户的信息列表:// index.js const express = require('express'); const app = express(); const fs = require("fs"); // 定义 读取用户的信息列表 的接口 app.get('/users', (req, res) => { fs.readFile( __dirname + "/" + "users.json", 'utf8', (err, data) => { console.log( data ); res.end( data ); }); }) const server = app.listen(8081, function () { const {address, port} = server.address(); console.log("server run in: http://%s:%s", address, port); })3. 添加用户这一步我们会创建 RESTful API 中的 /users,使用 POST 来添加用户记录:// index.js // 省略之前文件 只展示需要实现的接口 // mock 一条要新增的数据 const user = { "user4" : { "name" : "pingan", "password" : "password4", "profession" : "teacher", "id": 4 } } // 定义 添加用户记录 的接口 app.post('/users', (req, res) => { // 读取已存在的数据 fs.readFile( __dirname + "/" + "users.json", 'utf8', (err, data) => { data = JSON.parse( data ); data["user4"] = user["user4"]; console.log( data ); res.end( JSON.stringify(data)); }); })4. 获取用户详情这一步我们在 RESTful API 中的 URI 后面加上 /users/:id,使用 GET 来获取指定用户详情:// index.js // 省略之前文件 只展示需要实现的接口 // 定义 获取指定用户详情 的接口 app.get('/users/:id', (req, res) => { // 首先我们读取已存在的用户 fs.readFile( __dirname + "/" + "users.json", 'utf8', (err, data) => { data = JSON.parse( data ); const user = data["user" + req.params.id] console.log( user ); res.end( JSON.stringify(user)); }); })5. 删除指定用户这一步我们会创建 RESTful API 中的 /users,使用 DELETE 来删除指定用户:// index.js // 省略之前文件 只展示需要实现的接口 // mock 一条要删除的用户id const id = 2; app.delete('/users', (req, res) => { fs.readFile( __dirname + "/" + "users.json", 'utf8', (err, data) => { data = JSON.parse( data ); delete data["user" + id]; console.log( data ); res.end( JSON.stringify(data)); }); })四、REST 最佳实践1. URL 设计1.1 "动词 + 宾语"的操作指令结构客户端发出的数据操作指令都是"动词 + 宾语"的结构。如上面提到的,GET /user 这个命令,GET 是动词,/user 是宾语。根据 HTTP 规范,动词一律大写。动词通常有以下五种 HTTP 方法:GET:读取(Read) POST:新建(Create) PUT:更新(Update) PATCH:更新(Update),通常是部分更新 DELETE:删除(Delete)1.2 宾语必须是名词宾语就是 API 的 URL,是 HTTP 动词作用的对象。它应该是名词,不能是动词。比如,/users 是正确的,因为 URL 是名词,而下面就都是错误的了:/getUsers /createUsers /deleteUsers1.3 建议复数 URL因为 URL 是名词,没有单复数的限制,但是还是建议如果是一个集合,就使用复数形式。如 GET /users 来读取所有用户列表。1.4 避免多级 URL避免在多层级资源时,使用多级 URL。常见案例如获取某位用户的购买过的某一类商品:GET /users/100/product/120这种 URL 语意不明,也不利拓展,建议只有第一级,其他级别用查询字符串来表达:GET /users/100?product=1202. 准确的状态码表示HTTP 五大类状态码有100多种,每一种状态码都有标准的(或者约定的)解释,客户端只需查看状态码,就可以判断出发生了什么情况,所以服务器应该返回尽可能精确的状态码。这边列举几个经常使用的状态码介绍:303 See Other:表示参考另一个 URL。400 Bad Request:服务器不理解客户端的请求,未做任何处理。401 Unauthorized:用户未提供身份验证凭据,或者没有通过身份验证。403 Forbidden:用户通过了身份验证,但是不具有访问资源所需的权限。404 Not Found:所请求的资源不存在,或不可用。405 Method Not Allowed:用户已经通过身份验证,但是所用的 HTTP 方法不在他的权限之内。410 Gone:所请求的资源已从这个地址转移,不再可用。415 Unsupported Media Type:客户端要求的返回格式不支持。比如,API 只能返回 JSON 格式,但是客户端要求返回 XML 格式。422 Unprocessable Entity:客户端上传的附件无法处理,导致请求失败。429 Too Many Requests:客户端的请求次数超过限额。500 Internal Server Error:客户端请求有效,服务器处理时发生了意外。503 Service Unavailable:服务器无法处理请求,一般用于网站维护状态。3. 服务端响应3.1 应该返回 JSON 对象API 返回的数据格式应该是 JSON 一个对象。3.2 发生错误时,不要返回 200 状态码在发生错误时,如果还返回 200 状态码,前端需要解析返回数据才知道错误信息,这样实际上取消了状态码,是不恰当的。正确的做法应该是在错误时,返回对应错误状态码,并将错误信息返回:HTTP/1.1 400 Bad Request Content-Type: application/json { "error": "Invalid payoad.", "detail": { "surname": "This field is required." } }参考资料《维基百科 - 表现层状态转换》《RESTful风格的springMVC》《Node.js RESTful API》《RESTful API 最佳实践》
《全栈修炼》系列《【全栈修炼】OAuth2修炼宝典》CORS 和 CSRF 太容易混淆了,看完本文,你就清楚了。一、CORS 和 CSRF 区别先看下图:两者概念完全不同,另外常常我们也会看到 XSS ,这里一起介绍:CORS : Cross Origin Resourse-Sharing 跨站资源共享CSRF : Cross-Site Request Forgery 跨站请求伪造XSS : Cross Site Scrit 跨站脚本攻击(为与 CSS 区别,所以在安全领域叫 XSS)二、CORS1. 概念跨来源资源共享(CORS),亦译为跨域资源共享,是一份浏览器技术的规范,提供了 Web 服务从不同网域传来沙盒脚本的方法,以避开浏览器的同源策略,是 JSONP 模式的现代版。与 JSONP 不同,CORS 除了 GET 请求方法以外也支持其他的 HTTP 请求。用 CORS 可以让网页设计师用一般的 XMLHttpRequest,这种方式的错误处理比 JSONP 要来的好。另一方面,JSONP 可以在不支持 CORS 的老旧浏览器上运作。现代的浏览器都支持 CORS。—— 维基百科核心知识: CORS是一个W3C标准,它允许浏览器向跨源服务器,发出XMLHttpRequest 请求,从而克服 AJAX 只能同源使用的限制。因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信,即为了解决跨域问题。2. CORS 请求类型浏览器将 CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。简单请求一般包括下面两种情况:情况描述请求方法请求方法为:HEAD 或 GET 或 POST;HTTP 头信息HTTP 头信息不超出以下几种字段:AcceptAccept-LanguageContent-LanguageLast-Event-IDContent-Type:只限于三个值 application/x-www-form-urlencoded、multipart/form-data、text/plain凡是不同时满足上面两个条件,就属于非简单请求。3. 简单请求的 CORS 流程当浏览器发现我们的 AJAX 请求是个简单请求,便会自动在头信息中,增加一个 Origin 字段。Origin 字段用来说明本次请求的来源(包括协议 + 域名 + 端口号),服务端根据这个值来决定是否同意此次请求。当 Origin 指定的源不在许可范围,服务器会返回一个正常的 HTTP 回应,但浏览器会在响应头中发现 Access-Control-Allow-Origin 字段,便抛出异常。当 Origin 指定的源在许可范围,服务器返回的响应头中会多出几个头信息字段:除了上面图中的头信息,一般会有以下三个相关头信息:Access-Control-Allow-Origin该字段是必须的。表示许可范围的域名,通常有两种值:请求时 Origin 字段的值或者 *(星号)表示任意域名。Access-Control-Allow-Credentials该字段可选。布尔值,表示是否允许在 CORS 请求之中发送 Cookie 。若不携带 Cookie 则不需要设置该字段。当设置为 true 则 Cookie 包含在请求中,一起发送给服务器。还需要在 AJAX 请求中开启 withCredentials 属性,否则浏览器也不会发送 Cookie 。let xhr = new XMLHttpRequest(); xhr.withCredentials = true;注意: 如果前端设置 Access-Control-Allow-Credentials 为 true 来携带 Cookie 发起请求,则服务端 Access-Control-Allow-Origin 不能设置为 *。Access-Control-Expose-Headers该字段可选。可以设置需要获取的字段。因为默认 CORS 请求时,XMLHttpRequest 对象的getResponseHeader()方法只能拿到以下 6 个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。4. 非简单请求的 CORS 流程非简单请求情况如:请求方法是 PUT / DELETE 或者 Content-Type:application/json 类型的请求。在非简单请求发出 CORS 请求时,会在正式通信之前增加一次 “预检”请求(OPTIONS方法),来询问服务器,本次请求的域名是否在许可名单中,以及使用哪些头信息。当 “预检”请求 通过以后,才会正式发起 AJAX 请求,否则报错。4.1 预检请求OPTIONS /cors HTTP/1.1 Origin: http://api.bob.com Access-Control-Request-Method: PUT Access-Control-Request-Headers: X-Custom-Header User-Agent: Mozilla/5.0... ...“预检”请求 信息中包含两个特殊字段:Access-Control-Request-Method该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上例是 PUT。Access-Control-Request-Headers指定浏览器 CORS 请求额外发送的头信息字段,上例是 X-Custom-Header。4.2 预检响应HTTP/1.1 200 OK Access-Control-Allow-Origin: http://api.bob.com Access-Control-Allow-Methods: GET, POST, PUT Access-Control-Allow-Headers: X-Custom-Header Content-Type: text/html; charset=utf-8 Connection: Keep-Alive ...当预检请求通过以后,在预检响应头中,会返回 Access-Control-Allow- 开头的信息,其中 Access-Control-Allow-Origin 表示许可范围,值也可以是 *。当预检请求拒绝以后,在预检响应头中,不会返回 Access-Control-Allow- 开头的信息,并在控制台输出错误信息。三、CSRF1. 概念跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。跟跨网站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。—— 维基百科核心知识: 跨站点请求伪造请求。简单理解: 攻击者盗用你的身份,以你的名义发送恶意请求。常见场景:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账等等。造成影响:个人隐私泄露以及财产安全。2. CSRF 攻击流程上面描述了 CSRF 攻击的流程,其中受害者完成两个步骤:登录受信任网站 A ,并在本地生成保存Cookie;在不登出 A 情况下,访问病毒网站 B;可以理解为:若以上两个步骤没有都完成,则不会受到 CSRF 攻击。3. 服务端防御 CSRF 攻击服务端防御的方式有很多,思想类似,都是在客户端页面增加伪随机数。3.1 Cookie Hashing(所有表单都包含同一个伪随机数)最简单有效方式,因为攻击者理论上无法获取第三方的Cookie,所以表单数据伪造失败。以 php 代码为例:<?php //构造加密的Cookie信息 $value = "LeoDefenseSCRF"; setcookie("cookie", $value, time()+3600); ?>在表单里增加Hash值,以认证这确实是用户发送的请求。<?php $hash = md5($_COOKIE['cookie']); ?> <form method="POST" action="transfer.php"> <input type="text" name="toBankId"> <input type="text" name="money"> <input type="hidden" name="hash" value="<?=$hash;?>"> <input type="submit" name="submit" value="Submit"> </form>然后在服务器端进行Hash值验证。<?php if(isset($_POST['check'])) { $hash = md5($_COOKIE['cookie']); if($_POST['check'] == $hash) { doJob(); } else { //... } } else { //... } ?>这个方法个人觉得已经可以杜绝99%的CSRF攻击了,那还有1%呢....由于用户的 Cookie 很容易由于网站的 XSS 漏洞而被盗取,这就另外的1%。一般的攻击者看到有需要算Hash值,基本都会放弃了,某些除外,所以如果需要100%的杜绝,这个不是最好的方法。3.2 验证码思路是:每次用户提交都需要用户在表单中填写一个图片上的随机字符串,这个方案可以完全解决CSRF,但易用性差,并且验证码图片的使用涉及 MHTML 的Bug,可能在某些版本的微软IE中受影响。3.3 One-Time Tokens(不同的表单包含一个不同的伪随机值)需要注意“并行会话的兼容”。如果用户在一个站点上同时打开了两个不同的表单,CSRF保护措施不应该影响到他对任何表单的提交。考虑一下如果每次表单被装入时站点生成一个伪随机值来覆盖以前的伪随机值将会发生什么情况:用户只能成功地提交他最后打开的表单,因为所有其他的表单都含有非法的伪随机值。必须小心操作以确保CSRF保护措施不会影响选项卡式的浏览或者利用多个浏览器窗口浏览一个站点。php 实现如下:先是 Token 令牌生成函数(gen_token())和 Session 令牌生成函数(gen_stoken()):<?php function gen_token() { $token = md5(uniqid(rand(), true)); return $token; } function gen_stoken() { $pToken = ""; if($_SESSION[STOKEN_NAME] == $pToken){ $_SESSION[STOKEN_NAME] = gen_token(); } else{ } } ?>WEB表单生成隐藏输入域的函数: <?php function gen_input() { gen_stoken(); echo "<input type=\"hidden\" name=\"" . FTOKEN_NAME . "\" value=\"" . $_SESSION[STOKEN_NAME] . "\"> "; } ?>WEB表单结构:<?php session_start(); include("functions.php"); ?> <form method="POST" action="transfer.php"> <input type="text" name="toBankId"> <input type="text" name="money"> <? gen_input(); ?> <input type="submit" name="submit" value="Submit"> </FORM>服务端核对令牌这一步很简单,不做详细介绍。四、XSS注意: 本文简单介绍 XSS 知识,具体详细可以阅读 FEWY 写的 《跨站脚本攻击—XSS》segmentfault.com/a/119000002…1. 概念跨站脚本(英语:Cross-site scripting,通常简称为:XSS)是一种网站应用程序的安全漏洞攻击,是代码注入的一种。它允许恶意用户将代码注入到网页上,其他用户在观看网页时就会受到影响。这类攻击通常包含了HTML以及用户端脚本语言。—— 维基百科XSS 攻击,一般是指攻击者通过在网页中注入恶意脚本,当用户浏览网页时,恶意脚本执行,控制用户浏览器行为的一种攻击方式。常见 XSS 危害有:窃取用户Cookie,获取用户隐私,盗取用户账号。劫持用户(浏览器)会话,从而执行任意操作,例如进行非法转账、强制发表日志、发送电子邮件等。强制弹出广告页面,刷流量,传播跨站脚本蠕虫,网页挂马等。结合其他漏洞,如 CSRF 漏洞,实施进一步的攻击。2. XSS 分类3. XSS 防御3.1 方法1:浏览器自带防御 (X-XSS-Protection )现今主流浏览器(Internet Explorer,Chrome 和 Safari)带有 HTTP X-XSS-Protection 响应头,当检测到跨站脚本攻击(XSS)时,浏览器将停止加载页面。X-XSS-Protection 响应头有以下 4 个值:X-XSS-Protection: 0禁止XSS过滤。X-XSS-Protection: 1启用XSS过滤(通常浏览器是默认的)。 如果检测到跨站脚本攻击,浏览器将清除页面(删除不安全的部分)。X-XSS-Protection: 1; mode=block启用XSS过滤。 如果检测到攻击,浏览器将不会清除页面,而是阻止页面加载。X-XSS-Protection: 1; report=<reporting-uri>启用XSS过滤。 如果检测到跨站脚本攻击,浏览器将清除页面并使用CSP report-uri指令的功能发送违规报告。注意:这并不能完全防止反射型 XSS,而且也并不是所有浏览器都支持 X-XSS-Protection,存在兼容性问题。它只对反射型 XSS 有一定的防御力,其原理也只是检查 URL 和 DOM 中元素的相关性。3.2 方法2:转义即将常用特殊字符进行转义,避免攻击者使用构造特殊字符来注入脚本。需要在客户端和服务端,都对用户输入的数据进行转义。常见需要转义的特殊字符如 <,>,&,",'。转义方法:function escapeHTML(str) { if (!str) return ''; str = str.replace(/&/g, "&amp;"); str = str..replace(/</g, "&lt;"); str = str..replace(/>/g, "&gt;"); str = str..replace(/"/g, "&quot;"); str = str..replace(/'/g, "&#39;"); return str; };3.3 方法3:过滤常见于富文本内容,因为其需要保留 HTML,所以不能直接使用转义方法,而可以通过使用白名单,来允许特定的 HTML 标签及属性,来抵御 XSS 攻击。3.4 方法4:内容安全策略(CSP)内容安全策略(Content Security Policy,CSP),实质就是白名单制度,开发者明确告诉客户端,哪些外部资源可以加载和执行,大大增强了网页的安全性。两种方法可以启用 CSP。通过 HTTP 头信息的 Content-Security-Policy 的字段:Content-Security-Policy: script-src 'self'; object-src 'none'; style-src cdn.example.org third-party.org; child-src https:通过网页的 <meta> 标签<meta http-equiv="Content-Security-Policy" content="script-src 'self'; object-src 'none'; style-src cdn.example.org third-party.org; child-src https:">上面代码中,CSP 做了如下配置:脚本: 只信任当前域名<object>标签: 不信任任何 URL,即不加载任何资源样式表: 只信任 cdn.example.org 和 third-party.org页面子内容,如 <frame>、<iframe>: 必须使用HTTPS协议加载其他资源: 没有限制启用后,不符合 CSP 的外部资源就会被阻止加载。参考文章《跨域资源共享 CORS 详解》《CSRF & CORS》《浅谈CSRF攻击方式》《跨站脚本攻击—XSS》
2.2 autorun概念autorun 直译就是自动运行的意思,那么我们要知道这两个问题:自动运行什么?即:自动运行传入 autorun 的参数函数。import { observable, autorun } from 'mobx' class Store { @observable str = 'leo'; @observable num = 123; } let store = new Store() autorun(() => { console.log(`${store.str}--${store.num}`) }) // leo--123可以看出 autorun 自动被运行一次,并输出 leo--123 的值,显然这还不是自动运行。怎么触发自动运行?当修改 autorun 中任意一个可观察数据即可触发自动运行。// 紧接上部分代码 store.str = 'pingan' // leo--123 // pingan--123现在可以看到控制台输出这两个日志,证明 autorun 已经被执行两次。知识点:观察 computed 的数据import { observable, autorun } from 'mobx' class Store { @observable str = 'leo'; @observable num = 123; @computed get all(){ return `${store.str}--${store.num}` } } let store = new Store() autorun(() => { console.log(store.all) }) store.str = 'pingan' // leo--123 // pingan--123可以看出,这样将 computed 的值在 autorun 中进行观察,也是可以达到一样的效果,这也是我们实际开发中常用到的。知识点:computed 与 autorun 区别相同点:都是响应式调用的表达式;不同点:@computed 用于响应式的产生一个可以被其他 observer 使用的值;autorun 不产生新的值,而是达到一个效果(如:打印日志,发起网络请求等命令式的副作用);@computed中,如果一个计算值不再被观察了,MobX 可以自动地将其垃圾回收,而 autorun 中的值必须要手动清理才行。小结autorun 默认会执行一次,以获取哪些可观察数据被引用。autorun 的作用是在可观察数据被修改之后,自动去执行依赖可观察数据的行为,这个行为一直就是传入 autorun 的函数。2.3 when接收两个函数参数,第一个函数必须根据可观察数据来返回一个布尔值,当该布尔值为 true 时,才会去执行第二个函数,并且只会执行一次。import { observable, when } from 'mobx' class Leo { @observable str = 'leo'; @observable num = 123; @observable bool = false; } let leo = new Leo() when(() => leo.bool, () => { console.log('这是true') }) leo.bool = true // 这是true可以看出当 leo.bool 设置成 true 以后,when 的第二个方法便执行了。注意第一个参数,必须是根据可观察数据来返回的布尔值,而不是普通变量的布尔值。如果第一个参数默认值为 true,则 when 函数会默认执行一次。2.4 reaction接收两个函数参数,第一个函数引用可观察数据,并返回一个可观察数据,作为第二个函数的参数。reaction 第一次渲染的时候,会先执行一次第一个函数,这样 MobX 就会知道哪些可观察数据被引用了。随后在这些数据被修改的时候,执行第二个函数。import { observable, reaction } from 'mobx' class Leo { @observable str = 'leo'; @observable num = 123; @observable bool = false; } let leo = new Leo() reaction(() => [leo.str, leo.num], arr => { console.log(arr) }) leo.str = 'pingan' leo.num = 122 // ["pingan", 122] // ["pingan", 122]这里我们依次修改 leo.str 和 leo.num 两个变量,会发现 reaction 方法被执行两次,在控制台输出两次结果 ["pingan", 122] ,因为可观察数据 str 和 num 分别被修改了一次。实际使用场景:当我们没有获取到数据的时候,没有必要去执行存缓存逻辑,当第一次获取到数据以后,就执行存缓存的逻辑。2.5 小结computed 可以将多个可观察数据组合成一个可观察数据;autorun 可以自动追踪所引用的可观察数据,并在数据发生变化时自动触发;when 可以设置自动触发变化的时机,是 autorun 的一个变种情况;reaction 可以通过分离可观察数据声明,以副作用的方式对 autorun 做出改进;它们各有特点,互为补充,都能在合适场景中发挥重要作用。3. 修改可观察数据在上一部分内容中,我们了解到,对可观察的数据做出反应的时候,需要我们手动修改可观察数据的值。这种修改是通过直接向变量赋值来实现的,虽然简单易懂,但是这样会带来一个较为严重的副作用,就是每次的修改都会触发 autorun 或者 reaction 运行一次。多数情况下,这种高频的触发是完全没有必要的。比如用户对视图的一次点击操作需要很多修改 N 个状态变量,但是视图的更新只需要一次就够了。为了优化这个问题, MobX 引入了 action 。3.1 (@)actionaction 是修改任何状态的行为,使用 action 的好处是能将多次修改可观察状态合并成一次,从而减少触发 autorun 或者 reaction 的次数。可以理解成批量操作,即一次动作中包含多次修改可观察状态,此时只会在动作结束后,做一次性重新计算和反应。action 也有两种使用方法,这里以 decorate 方式来介绍。import { observable, computed, reaction, action} from 'mobx' class Store { @observable string = 'leo'; @observable number = 123; @action bar(){ this.string = 'pingan' this.number = 100 } } let store = new Store() reaction(() => [store.string, store.number], arr => { console.log(arr) }) store.bar() // ["pingan", 100]当我们连续去修改 store.string 和 store.number 两个变量后,再运行 store.bar() 会发现,控制台值输出一次 ["pingan", 100] ,这就说明 reaction 只被执行一次。知识点:action.bound另外 action 还有一种特殊使用方法:action.bound,常常用来作为一个 callback 的方法参数,并且执行效果也是一样:import { observable, computed, reaction, action} from 'mobx' class Store { @observable string = 'leo'; @observable number = 123; @action.bound bar(){ this.string = 'pingan' this.number = 100 } } let store = new Store() reaction(() => [store.string, store.number], arr => { console.log(arr) }) let bar = store.bar; function foo(fun){ fun() } foo(bar) //["pingan", 100]知识点:runInAction(name?, thunk)runInAction 是个简单的工具函数,它接收代码块并在(异步的)动作中执行。这对于即时创建和执行动作非常有用,例如在异步过程中。runInAction(f) 是 action(f)() 的语法糖。import { observable, computed, reaction, action} from 'mobx' class Store { @observable string = 'leo'; @observable number = 123; @action.bound bar(){ this.string = 'pingan' this.number = 100 } } let store = new Store() reaction(() => [store.string, store.number], arr => { console.log(arr) }) runInAction(() => { store.string = 'pingan' store.number = 100 })//["pingan", 100]四、 Mobx-React 简单实例这里以简单计数器为例,实现点击按钮,数值累加的简单操作,如图:在这个案例中,我们引用 mobx-react 库来实现,很明显可以看出 mobx-react 是作为 mobx 和 react 之前的桥梁。它将 react 组件转化为对可观察数据的反应,也就是将组件的 render 方法包装成 autorun 方法,使得状态变化时能自动重新渲染。详细可以查看:www.npmjs.com/package/mob… 。接下来开始我们的案例:1. 安装依赖和配置webpack由于配置和前面第二节介绍差不多,所以这里会以第二节的配置为基础,添加配置。首先安装 mobx-react 依赖:cnpm i mobx-react -D修改 webpack.config.js,在 presets 配置中添加 react 进来:// ... 省略其他 - entry: path.resolve(__dirname, 'src/index.js'), + entry: path.resolve(__dirname, 'src/index.jsx'), module: { rules: [{ test: /\.jsx?$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { - presets: ['env'], + presets: ['env', 'react'], plugins: ['transform-decorators-legacy', 'transform-class-properties'] } } }] },2. 初始化 React 项目这里初始化一下我们本次项目的简单骨架:// index.jsx import { observable, action} from 'mobx'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import {observer, PropTypes as observablePropTypes} from 'mobx-react' class Store { } const store = new Store(); class Bar extends Component{ } class Foo extends Component{ } ReactDOM.render(<Foo />, document.querySelector("#root"))这些组件对应到我们最后页面效果如图:2. 实现 Store 类Store 类用于存储数据。class Store { @observable cache = { queue: [] } @action.bound refresh(){ this.cache.queue.push(1) } }3. 实现 Bar 和 Foo 组件实现代码如下:@observer class Bar extends Component{ static propTypes = { queue: observablePropTypes.observableArray } render(){ const queue = this.props.queue; return <span>{queue.length}</span> } } class Foo extends Component{ static propTypes = { cache: observablePropTypes.observableObject } render(){ const cache = this.props.cache; return <div><button onClick={this.props.refresh}>点击 + 1</button> 当前数值:<Bar queue={cache.queue} /></div> } }这里需要注意:可观察数据类型中的数组,实际上并不是数组类型,这里需要用 observablePropTypes.observableArray 去声明它的类型,对象也是一样。@observer 在需要根据数据变换,而改变UI的组件去引用,另外建议有使用到相关数据的类都引用。事实上,我们只需要记住 observer 方法,将所有 React 组件用 observer 修饰,就是 react-mobx 的用法。4. 使用 Foo 组件最后我们使用 Foo 组件,需要给它传递两个参数,这样 Bar 组件才能拿到并使用:ReactDOM.render( <Foo cache={store.cache} refresh={store.refresh}/>, document.querySelector("#root") )结尾本文参考:《MobX 官方文档》茵风泳月《MobX 入门基础教程》
一、MobX 介绍首先看下官网介绍:MobX 是一个经过战火洗礼的库,它通过透明的函数响应式编程(transparently applying functional reactive programming - TFRP)使得状态管理变得简单和可扩展。MobX背后的哲学很简单: 任何源自应用状态的东西都应该自动地获得。 其中包括UI、数据序列化、服务器通讯,等等。核心重点就是:MobX 通过响应式编程实现简单高效,可扩展的状态管理。React 和 Mobx 关系React 和 MobX 相辅相成,相互合作。官网介绍:React 通过提供机制把应用状态转换为可渲染组件树并对其进行渲染。而MobX提供机制来存储和更新应用状态供 React 使用。Mobx 工作流程这里先了解下大概整理流程,接下来会结合代码,介绍每一个部分。本文概要本文使用的是 MobX 5 版本,主要将从以下几个方面介绍 MobX 的使用:配置 Webpack 的 MobX 开发环境MobX 常用 API 介绍(主要介绍与可观察数据相关的操作)MobX 简单实例二、配置 Webpack 的 MobX 开发环境安装 webpack 和 babel 依赖包:cnpm i webpack webpack-cli babel-core babel-preset-env babel-loader -D安装 MobX 相关依赖包:cnpm i mobx-react -D cnpm i babel-plugin-transform-class-properties -D cnpm i babel-plugin-transform-decorators-legacy -D webpack.config.js 中添加配置:注意:transform-decorators-legacy 一定放在第一个。const path = require('path') const config = { mode: 'development', entry: path.resolve(__dirname, 'src/index.js'), output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js' }, module: { rules: [{ test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['env'], plugins: ['transform-decorators-legacy', 'transform-class-properties'] } } }] }, devtool: 'inline-source-map' } module.exports = config三、MobX 常用 API 介绍1. 设置可观察数据(observable)1.1 (@)observableobservable 是一种让数据的变化可以被观察的方法,底层是通过把该属性转化成 getter / setter 来实现的。。observable 值可以是 JS原始数据类型、引用类型、普通对象、类实例、数组和映射。observable 使用对于JS原始类型(Number/String/Boolean), 使用observable.box()方法设置:const num = observable.box(99) const str = observable.box('leo') const bool = observable.box(true) // 获取原始值 get() console.log(num.get(),str.get(),bool.get()) // 99 "leo" true // 修改原始值 set(params) num.set(100); str.set('pingan'); bool.set(false); console.log(num.get(),str.get(),bool.get()) // 100 "pingan" false对于数组、对象类型,使用 observable() 方法设置:const list = observable([1, 2, 4]); list[2] = 3; list.push(5) // 可以调用数组方法 console.log(list[0], list[1], list[2], list[3]) // 1 2 3 5 const obj = observable({a: '11', b: '22'}) console.log(obj.a, obj.b) // 11 22 obj.a = "leo"; console.log(obj.a, obj.b) // leo 22需要注意的是:应该避免下标越界去方法数组中的值,这样的数据将不会被 MobX 所监视:const list = observable([1, 2, 4]); // 错误 console.log(list[9]) // undefined因此在实际开发中,需要注意数组长度的判断。对于映射(Map)类型,使用 observable.map() 方法设置:const map = observable.map({ key: "value"}); map.set("key", "new value"); console.log(map.has('key')) // true map.delete("key"); console.log(map.has('key')) // false@observable 使用MobX 也提供使用装饰器 @observable 来将其转换成可观察的,可以使用在实例的字段和属性上。import {observable} from "mobx"; class Leo { @observable arr = [1]; @observable obj = {}; @observable map = new Map(); @observable str = 'leo'; @observable num = 100; @observable bool = false; } let leo = new Leo() console.log(leo.arr[0]) // 1相比于前面使用 observable.box() 方法对JS原始类型(Number/String/Boolean)进行定义,装饰器 @observable 则可以直接定义这些类型。原因是装饰器 @observable 更进一步封装了 observable.box()。2. 响应可观察数据的变化2.1 (@)computed计算值(computed values)是可以根据现有的状态或其它计算值进行组合计算的值。可以使实际可修改的状态尽可能的小。此外计算值还是高度优化过的,所以尽可能的多使用它们。可以简单理解为:它是相关状态变化时自动更新的值,可以将多个可观察数据合并成一个可观察数据,并且只有在被使用时才会自动更新。知识点:使用方式使用方式1:声明式创建import {observable, computed} from "mobx"; class Money { @observable price = 0; @observable amount = 2; constructor(price = 1) { this.price = price; } @computed get total() { return this.price * this.amount; } } let m = new Money() console.log(m.total) // 2 m.price = 10; console.log(m.total) // 20使用方式2:使用 decorate 引入import {decorate, observable, computed} from "mobx"; class Money { price = 0; amount = 2; constructor(price = 1) { this.price = price; } get total() { return this.price * this.amount; } } decorate(Money, { price: observable, amount: observable, total: computed }) let m = new Money() console.log(m.total) // 2 m.price = 10; console.log(m.total) // 20使用方式3:使用 observable.object 创建observable.object 和 extendObservable 都会自动将 getter 属性推导成计算属性,所以下面这样就足够了:import {observable} from "mobx"; const Money = observable.object({ price: 0, amount: 1, get total() { return this.price * this.amount } }) console.log(Money.total) // 0 Money.price = 10; console.log(Money.total) // 10注意点如果任何影响计算值的值发生变化了,计算值将根据状态自动进行变化。如果前一个计算中使用的数据没有更改,计算属性将不会重新运行。 如果某个其它计算属性或 reaction 未使用该计算属性,也不会重新运行。 在这种情况下,它将被暂停。知识点:computed 的 settercomputed 的 setter 不能用来改变计算属性的值,而是用来它里面的成员,来使得 computed 发生变化。这里我们使用 computed 的第一种声明方式为例,其他几种方式实现起来类似:import {observable, computed} from "mobx"; class Money { @observable price = 0; @observable amount = 2; constructor(price = 1) { this.price = price; } @computed get total() { return this.price * this.amount; } set total(n){ this.price = n + 1 } } let m = new Money() console.log(m.total) // 2 m.price = 10; console.log(m.total) // 20 m.total = 6; console.log(m.total) // 14从上面实现方式可以看出,set total 方法中接收一个参数 n 作为 price 的新值,我们调用 m.total 后设置了新的 price,于是 m.total 的值也随之发生改变。注意:一定在 geeter 之后定义 setter,一些 typescript 版本会认为声明了两个名称相同的属性。知识点:computed(expression) 函数一般可以通过下面两种方法观察变化,并获取计算值:方法1: 将 computed 作为函数调用,在返回的对象使用 .get() 来获取计算的当前值。方法2: 使用 observe(callback) 来观察值的改变,其计算后的值在 .newValue 上。import {observable, computed} from "mobx"; let leo = observable.box('hello'); let upperCaseName = computed(() => leo.get().toUpperCase()) let disposer = upperCaseName.observe(change => console.log(change.newValue)) leo.set('pingan')更详细的 computed 参数可以查看文档:《Computed 选项》。知识点:错误处理计算值在计算期间抛出异常,则此异常会被捕获,并在读取其值的时候抛出异常。抛出异常不会中断跟踪,所有计算值可以从异常中恢复。import {observable, computed} from "mobx"; let x = observable.box(10) let y = observable.box(2) let div = computed(() => { if(y.get() === 0) throw new Error('y 为0了') return x.get() / y.get() }) div.get() // 5 y.set(0) // ok div.get() // 报错,y 为0了 y.set(5) div.get() // 恢复正常,返回 2小结用法:computed(() => expression)computed(() => expression, (newValue) => void)computed(() => expression, options)@computed({equals: compareFn}) get classProperty() { return expression; }@computed get classProperty() { return expression; }还有各种选项可以控制 computed 的行为。包括:equals: (value, value) => boolean 用来重载默认检测规则的比较函数。 内置比较器有: comparer.identity, comparer.default, comparer.structural;requiresReaction: boolean 在重新计算衍生属性之前,等待追踪的 observables 值发生变化;get: () => value) 重载计算属性的 getter;set: (value) => void 重载计算属性的 setter;keepAlive: boolean 设置为 true 以自动保持计算值活动,而不是在没有观察者时暂停;
最近 ECMAScript2019,最新提案完成:tc39 Finished Proposals,我这里也是按照官方介绍的顺序进行整理,如有疑问,可以查看官方介绍啦~另外之前也整理了 《ES6/ES7/ES8/ES9系列》,可以一起看哈。1. 可选的 catch 绑定1.1 介绍在 ECMAScript2019 最新提案中,支持我们在使用 try catch 错误异常处理时,选择性的给 catch 传入参数。即我们可以不传入 catch 参数。正常使用 try catch:try { // todo } catch (err){ console.log('err:',err) }在 ES10 中可以这么使用:try { // todo } catch { // todo }1.2 使用场景当我们不需要对 catch 返回的错误信息进行处理时,比如:我们对于一些数据处理,经常会出现格式报错,但是我们并不关心这个错误,我们只需要继续处理,或重新请求数据等。这种情况,我们就可以使用这个新特性,当然,还是需要根据实际情况考虑。2. JSON.superset2.1 介绍来源背景:由于在 ES2019 之前不支持转义行分隔符 (\u2028) 和段落分隔符 (\u2029) 字符,并且在解析过程中会报错: SyntaxError: Invalid or unexpected token。const LS = ""; const PS = eval("'\u2029'");// SyntaxError: Invalid or unexpected token解决方案:JSON 语法由** ECMA-404** 定义并由 RFC 7159 永久修复,允许行分隔符 (\u2028) 和段落分隔符 (\u2029) 字符,直接出现在字符串中。2.2 使用在 ES10 中,我们就可以直接使用 eval("'\u2029'"); 而不会再提示错误。3. Symbol.prototype.description3.1 介绍在 ES6 中引入 Symbol 这个基本数据类型,可以实现一些数据内省等高级功能。这次 ES10 中,为 Symbol 类型增加 Symbol.prototype.description 的一个访问器属性,用来获取 Symbol 类型数据的描述信息(description)。3.2 使用MDN 上的案例介绍:console.log(Symbol('pingan8787').description); // expected output: "pingan8787" console.log(Symbol.iterator.description); // expected output: "Symbol.iterator" console.log(Symbol.for('leo').description); // expected output: "leo" console.log(Symbol('pingan8787').description + ' and leo!'); // expected output: "pingan8787 and leo!"另外我们也可以这么使用:let pingan = Symbol('pingan8787').description; console.log(pingan === 'pingan8787'); // true4. Function.prototype.toString4.1 介绍在 ES10 之前,我们对一个函数调用 toString() 方法,返回的结果中会将注释信息去除。在 ES10 之后,函数再调用 toString() 方法,将准确返回原有内容,包括空格和注释等:let pingan8787 = function(){ // do something console.log('leo') } pingan8787.toString(); /** "function(){ // do something console.log('leo') }" */5. Object.fromEntries5.1 介绍Object.fromEntries 是 ES10 中新的静态方法,用于将键值对列表转换为对象。Object.fromEntries() 方法接收一个键值对的列表参数,并返回一个带有这些键值对的新对象。这个迭代参数应该是一个能够实现 @iterator 方法的的对象,返回一个迭代器对象。它生成一个具有两个元素的类数组的对象,第一个元素是将用作属性键的值,第二个元素是与该属性键关联的值。Object.fromEntries() 是 Object.entries 的反转。5.2 使用Object.entries 和 Object.fromEntries() 互转let leo = { name: 'pingan8787', age: 10}; let arr = Object.entries(leo); console.log(arr);// [["name", "pingan8787"],["age", 10]] let obj = Object.fromEntries(arr); console.log(obj);// {name: "pingan8787", age: 10}Map 转化为 Objectconst map = new Map([ ['name', 'pingan8787'], ['age', 10] ]); const obj = Object.fromEntries(map); console.log(obj); // {name: "pingan8787", age: 10}Array 转化为 Objectconst arr = [ ['name', 'pingan8787'], ['age', 10] ]; const obj = Object.fromEntries(arr); console.log(obj); // {name: "pingan8787", age: 10}6. 更友好的 JSON.stringify6.1 介绍更友好的 JSON.stringify,对于一些超出范围的 Unicode 字符串,为其输出转义序列,使其成为有效 Unicode 字符串。6.2 使用// Non-BMP characters still serialize to surrogate pairs. JSON.stringify('𝌆') // → '"𝌆"' JSON.stringify('\uD834\uDF06') // → '"𝌆"' // Unpaired surrogate code units will serialize to escape sequences. JSON.stringify('\uDF06\uD834') // → '"\\udf06\\ud834"' JSON.stringify('\uDEAD') // → '"\\udead"'7. String.prototype.{trimStart,trimEnd}7.1 String.prototype.trimStarttrimStart() 方法从字符串的开头删除空格,返回一个新字符串,表示从其开头(左端)剥离空格的调用字符串,不会直接修改原字符串本身。trimLeft()是此方法的别名。let pingan8787 = ' Hello pingan8787! '; console.log(pingan8787); // " Hello pingan8787! "; console.log(pingan8787.length); // 23; console.log(pingan8787.trimStart()); // "Hello pingan8787! "; console.log(pingan8787.trimStart().length); // 20; 7.2 String.prototype.trimEndtrimEnd() 方法从一个字符串的右端移除空白字符,返回一个新字符串,表示从其(右)端剥去空白的调用字符串,不会直接修改原字符串本身。trimRight()是此方法的别名。let pingan8787 = ' Hello pingan8787! '; console.log(pingan8787); // " Hello pingan8787! "; console.log(pingan8787.length); // 23; console.log(pingan8787.trimEnd()); // " Hello pingan8787!"; console.log(pingan8787.trimEnd().length); // 20; 8. Array.prototype.{flat,flatMap}在 ES10 之前,我们要将一个数组打平,由于官方没有对应 API,我们可能需要 lodash 活着手写循环去操作。8.1 Array.prototype.flat在 ES10 中,官方新增一个 Array.prototype.flat 方法,将数组第一层数据打平,也仅限第一层。如果我们需要将多层递归,则需要显式传入参数:[1,2,3,[1,2,[3, [4]]]].flat(2); // [1, 2, 3, 1, 2, 3, [4]]8.2 Array.prototype.flatMap在 ES10 中,官方还增加了 Array.prototype.flatMap 方法,其实就是 flat 和 map 一起组合操作:[1,3,5].map(x => [x * x]); // [[1],[9],[25]] [1,3,5].flatMap(x => [x * x]); // [1,9,25]
在最近开发移动端页面,遇到这么一个情况:当页面宽度 100% 时,高度为宽度一半,并随手机宽度变化依然是一半。于是我们就需要实现一个宽度自适应,高度为宽度一半的容器。这里先以高度为宽度一半为例,也可以是其他任意比例。一、思考如何实现这个问题类似于:我们在移动端页面,上面有一张宽度 100% 的图片,如果我们没设置高度,则图片会根据原有尺寸,等比缩放。我们可以借助这个想法,根据元素高度,来为元素设置一个相应比例的高度即可。二、实现方法1 - 通过 vw 视口单位实现所谓视口单位(viewport units)是相对于视口(viewport)的尺寸而言,100vw 等于视口宽度的 100%,即 1vw 等于视口宽度的 1%。我们就可以利用这个特性,实现移动端的宽高等比自适应容器。<div class="box"> <img src="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/8/7/16c6c7383db0b7dd~tplv-t2oaga2asx-image.image" /> </div>*{ margin:0; padding:0 } .box{ width:100%; height:51.5vw } .box img{ width:100%; }为什么 .box 高度为 51.5vw 呢?原因是图片原来的尺寸是 884 * 455的宽高比例,即 455 / 884 = 51.5%。这个方法相比原来图片的等比缩放,有个优点:无论图片是否加载成功,容器高度始终是计算完成,不会造成页面抖动,也不会造成页面重绘,从而提升性能。下面看看这种情况下,图片加载成功和失败的对比:三、实现方法2 - 通过子元素 padding 实现通过设置子元素的 padding 属性来实现,是比较常用,也是效果比较好的一种,这里需要理解的是:子元素的 padding 属性百分比的值是先对父容器的宽度而言。这里看下面代码和效果图理解下:<div class="box"> <div class="text">我是王平安,pingan8787</div> </div>.box{ width: 200px; } .text{ padding: 10%; }分析:这里我们将父容器 .box 宽度设置为 200px,子元素 .text 的 padding:10% ,因此 .box 的 padding 计算结果为 20px;接下来结合主题,我们利用这个原理,来实现等比例的问题:<div class="box"> <div class="text"> <img src="https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/8/7/16c6c7383db0b7dd~tplv-t2oaga2asx-image.image" /> </div> </div>.box{ width: 100%; } .text{ overflow: hidden; height: 0; padding-bottom: 51.5%; } .box .text img{ width: 100%; }这里 .text 的 padding-bottom: 51.5%; 也是按照第一个方法,用图片原始尺寸的宽高比计算出来的,需要注意,这里将 .text 设置 height: 0; 会出现高度比实际高的问题,因此为了避免这个情况,就需要设置 height: 0;。于是我们通过2种方法解决了这个问题。
五、在 Android 平台下如何调试 WebView?1. 在 Chrome 浏览器上调试参考文章:《Android调试webview》1.1 条件:在 Android 设备或模拟器运行 Android4.4 或更高版本,Android 设备上启用 USB调试模式。Chrome 30 或更高版本。更强大的 WebView 界面调试功能需要 Chrome31 或更高版本。Android 应用程序中的 WebView 配置为可调试模式。1.2 Android 代码中配置 WebView 为可调试:if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { WebView.setWebContentsDebuggingEnabled(true); } 注意 web 调测不受 app manifest 文件中 debuggable 标记状态的影响,如果希望仅 debuggable 为 true 时才能使用 web 调测,那么运行时检测此标记,如下:if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { if ( 0 != ( getApplcationInfo().flags &= ApplicationInfo.FLAG_DEBUGGABLE ) ) { WebView.setWebContentsDebuggingEnabled(true); } } 1.3 手机开启 USB 调试选项,并用 USB 连接电脑:开启 Android 手机的开发者选项,一般在 系统设置 - Android版本 进行多次点击会触发开启开发者选项,然后进入开发者选项页面,开启USB调试。为了避免每次调试时看到此警告,勾选“总是允许从这台计算机”,并单击“确定”。1.4 在 Chrome 中启用设置“USB web debugging”(不会影响WebView):在 Chrome 上访问 chrome://inspect/#devices 或 about:inspect 访问已启用调试的 WebView 列表,需要能上谷歌。然后在 WebView 列表中选择你要调试的页面,点击“ Inspect ”选项,跟调试 PC 网页一样,使用 Chrome 控制台进行调试。1.5 小技巧:(1)访问 chrome://inspect/#devices 如果 chrome 没有检测到 Remote Target 中的页面,可能需要安装一下 Chrome 的 ADB 插件,也可以在 Chrome 访问 https://chrome-devtools-frontend.appspot.com;(2)对于腾讯系的 APP,默认采用 X5内核 ,我们可以在 APP 内部打开 https://debugx5.qq.com/ 来使用它的Vconsole调试工具进行调试。 å2. 使用 DebugGap 调试参考文章:《Android下的webview调试》2.1 Windows 下载 DebugGap 并配置:在电脑上面下载 Windows 版本的 DebugGap 软件包(下载链接:DebugGap),下载完成后解压下来。安装完成后,运行 DebugGap ,开始配置:通常情况下,DebugGap 可以自动获取IP,并设置默认的端口,如果没有,你可以手动设置;点击“连接”按钮启动各种客户端的侦听器;2.2 在客户端上配置:在调试项目中要进行测试的 HTML 界面中引入 debuggap.js。<script src="debuggap.js" type="text/javascript"></script>当调试项目的加载时,您的应用程序将会有一个蓝色的地方,点击会出现一个四叶三叶草的东西,点击“配置”,显示配置页面。输入与远程 DebugGap 上的主机和端口相同的主机和端口,例如 192.168.1.4:11111,然后点击“连接”按钮。1.4电脑端远程 DebugGap 将检测即将到来的客户端,开发人员可以单击每个客户端进行调试。六、在 iOS 平台下如何调试 WebView?参考文章:《iOS之Safari调试webView/H5页面》一般我们通过 Mac 的 Safari浏览器 来调试,但是要注意两点:如果调试的是 APP 中 WebView 的页面,则需要这个 APP 的包支持调试,如果不能调试,需要让 iOS 开发人员重签名 APP(可能需要将我们 iOS 设备的 ID 写入到可信任设备列表中,然后使用 iTunes 安装客户端提供的测试包即可)。如果调试的是 H5 页面,可以直接在手机的 Safari浏览器 打开直接调试。下面开始说说在 Mac 上如何调试:1. 开启 Safari 开发菜单先将 iPhone 连接到 Mac,在 Mac 的 Safari 偏好设置中,开启开发菜单。具体步骤为:Safari -> 偏好设置… -> 高级 -> 勾选在菜单栏显示“开发”菜单。2. iPhone 开启 Web检查器具体步骤为:设置 -> Safari -> 高级 -> Web 检查器。3. 调试 APP 内的 WebView参考文章:《前端 WEBVIEW 指南之 IOS 调试篇》在 Safari-> 开发中,看到自己的设备以及 WebView 中网页,点击后即可开启对应页面的 Inspector ,可以用来进行断点调试。七、在内嵌版调试过程中,Fiddler 或 Charles 能起到什么作用?这两者都是强大的抓包工具,原理是以web代理服务器的形式进行工作的,使用的代理地址是:127.0.0.1,端口默认为8888,我们也可以通过设置进行修改。代理就是在客户端和服务器之间设置一道关卡,客户端先将请求数据发送出去后,代理服务器会将数据包进行拦截,代理服务器再冒充客户端发送数据到服务器;同理,服务器将响应数据返回,代理服务器也会将数据拦截,再返回给客户端。Fiddler 或 Charles 的主要作用有:可以代理请求,用来查看页面发送的请求和接收的响应;可以修改请求的响应,用来模拟自己想要的数据;可以模拟网络请求的速度;可以代理服务器的静态资源请求,指向本地文件,省去频繁发布 H5 包,达到快速调试目的;补充链接:《Fiddler工具使用介绍一》八、调试企业微信、微信和钉钉版时,可以使用哪些工具?1. 调试企业微信、微信微信开发者工具,可以用来调试页面基本功能;企业微信接口调试工具,可以用来调试企业微信的接口;2. 调试钉钉钉钉Android开发版,用来调试Android上的钉钉应用;3. 通用Fiddler 或 Charles,可以拦截接口替换文件,来调试应用;九、常见的调试技巧有哪些?1. Chrome 控制台调试参考文章:《前端常见调试技巧篇总结(持续更新...)》1.1 Source 面板断点调试 JS从左到右,各个图标表示的功能分别为:Pause/Resume script execution:暂停/恢复脚本执行(程序执行到下一断点停止)。Step over next function call:执行到下一步的函数调用(跳到下一行)。Step into next function call:进入当前函数。Step out of current function:跳出当前执行函数。Deactive/Active all breakpoints:关闭/开启所有断点(不会取消)。Pause on exceptions:异常情况自动断点设置。1.2 Element 面板调试 DOM:右击元素,选择 break on 选项:Subtree modifications 选项,是指当节点内部子节点变化时断点;Attributes modifications 选项,是指当节点属性发生变化时断点;node removal 选项,是指当节点被移除时断点;2. console 调试参考文章:《Console调试常用用法》2.1 显示信息的命令:console.log("normal"); // 用于输出普通信息 console.info("information"); // 用于输出提示性信息 console.error("error"); // 用于输出错误信息 console.warn("warn"); // 用于输出警示信息 console.clear(); // 清空控制台信息2.2 计时功能:console.time() 和 console.timeEnd() :console.time("控制台计时器"); for(var i = 0; i < 10000; i++){ for(var j = 0; j < 10000; j++){} } console.timeEnd("控制台计时器")2.3 信息分组:console.group() 和 console.groupEnd():console.group("第一组信息"); console.log("第一组第一条:我的博客"); console.log("第一组第二条:CSDN"); console.groupEnd(); console.group("第二组信息"); console.log("第二组第一条:程序爱好者QQ群"); console.log("第二组第二条:欢迎你加入"); console.groupEnd();2.4 将对象以树状结构展现:console.dir() 可以显示一个对象所有的属性和方法:var info = { name : "Alan", age : "27", grilFriend : "nothing", getName : function(){ return this.name; } } console.dir(info);2.5 显示某个节点的内容:console.dirxml() 用来显示网页的某个节点( node) 所包含的 html/xml 代码:var node = document.getElementById("info"); node.innerHTML += "<p>追加的元素显示吗</p>"; console.dirxml(node);2.5 统计代码被执行的次数:使用 console.count():function myFunction(){ console.count("myFunction 被执行的次数"); } myFunction(); //myFunction 被执行的次数: 1 myFunction(); //myFunction 被执行的次数: 2 myFunction(); //myFunction 被执行的次数: 32.6 输出表格:console.table(mytable);3. 调试各种页面尺寸虽然把各种各样的手机都摆在桌子上看起来很酷,但却很不现实。但是,浏览器内却提供了你所需要的一切。进入检查面板点击“切换设备模式”按钮。这样,就可以在窗口内调整视窗的大小。4. debugger 断点具体的说就是通过在代码中添加" debugger; "语句,当代码执行到该语句的时候就会自动断点。结语对于初入混合应用开发的小伙伴,还有经常需要调试混合应用的小伙伴,相信会有帮助😁大家加油~
前言我们大前端团队内部 📖每周一练 的知识复习计划继续加油,本篇文章是 《Hybrid APP 混合应用专题》 主题的第二期和第三期的合集。这一期共整理了 10 个问题,和相应的参考答案,文字和图片较多,建议大家可以收藏,根据文章目录来阅读。之前分享的每周内容,我都整理到掘金收藏集 📔《EFT每周一练》 上啦,欢迎点赞收藏咯💕💕。内容回顾:《EFT 每周分享 —— Hybrid App 应用开发中 5 个必备知识点复习》《EFT 每周分享 —— HTTP 的15个常见知识点复习》《EFT 每周分享 —— 数据结构与算法合集》文章收录:本系列所有文章,都将收录在 Github 上,欢迎点击查阅。注:本文整理部分资料来源网络,有些图片/段落找不到原文出处,如有侵权,联系删除。一、iOS 平台中 UIWebView 与 WKWebView 有什么区别?参考文章:《UIWebView与WKWebView》UIWebView 是苹果继承于 UIView 封装的一个加载 web 内容的类,它可以加载任何远端的web数据展示在你的页面上,你可以像浏览器一样前进后退刷新等操作。不过苹果在 iOS8 以后推出了 WKWebView 来加载 Web,并应用于 iOS 和 OSX 中,它取代了 UIWebView 和 WebView ,在两个平台上支持同一套 API。它脱离于 UIWebView 的设计,将原本的设计拆分成14个类,和3个代理协议,虽然是这样但是了解之后其实用法比较简单,依照职责单一的原则,每个协议做的事情根据功能分类。WKWebView 与 UIWebView 的区别:WKWebView 的内存远远没有 UIWebView 的开销大,而且没有缓存;WKWebView 拥有高达 60FPS 滚动刷新率及内置手势;WKWebView 支持了更多的 HTML5 特性;WKWebView 高效的 app 和 web 信息交换通道;WKWebView 允许 JavaScript 的 Nitro 库加载并使用, UIWebView 中限制了;WKWebView 目前缺少关于页码相关的 API;WKWebView 提供加载网页进度的属性;WKWebView 使用 Safari 相同的 JavaScript 引擎;WKWebView 增加加载进度属性: estimatedProgress ;WKWebView 不支持页面缓存,需要自己注入 cookie , 而 UIWebView 是自动注入 cookie ;WKWebView 无法发送 POST 参数问题;WKWebView 可以和js直接互调函数,不像 UIWebView 需要第三方库 WebViewJavascriptBridge 来协助处理和 js 的交互;注意:大多数App需要支持 iOS7 以上的版本,而 WKWebView 只在 iOS8 后才能用,所以需要一个兼容性方案,既 iOS7 下用 UIWebView ,iOS8 后用 WKWebView 。但是目前 IOS10 以下的系统以及很少了,小结:WKWebView 相较于 UIWebView 在整体上有较大的提升,满足 iOS 上面使用同一套控件的功能,同时对整个内存的开销以及滚动刷新率和 JS 交互做了优化的处理。依据职责单一原则,拆分成了三个协议去实现 WebView 的响应,解耦了 JS 交互和加载进度的响应处理。WKWebView 没有做缓存处理,所以对网页需要缓存的加载性能要求没那么高的还是可以考虑 UIWebView 。二、WKWebView 有哪一些坑?参考文章:《WKWebView 那些坑》1. WKWebView 白屏问题WKWebView 实际上是个多进程组件,这也是它加载速度更快,内存暂用更低的原因。在 UIWebView 上当内存占用太大的时候,App Process 会 crash;而在 WKWebView 上当总体的内存占用比较大的时候,WebContent Process 会 crash,从而出现白屏现象。解决办法:借助 WKNavigtionDelegate当 WKWebView 总体内存占用过大,页面即将白屏的时候,系统会调用上面的回调函数,我们在该函数里执行[webView reload](这个时候 webView.URL 取值尚不为 nil)解决白屏问题。在一些高内存消耗的页面可能会频繁刷新当前页面,H5侧也要做相应的适配操作。检测 webView.title 是否为空并不是所有 H5 页面白屏的时候都会调用上面的回调函数,比如,最近遇到在一个高内存消耗的 H5 页面上 present 系统相机,拍照完毕后返回原来页面的时候出现白屏现象(拍照过程消耗了大量内存,导致内存紧张,WebContent Process 被系统挂起),但上面的回调函数并没有被调用。在 WKWebView 白屏的时候,另一种现象是 webView.titile 会被置空, 因此,可以在 viewWillAppear 的时候检测 webView.title 是否为空来 reload 页面。2. WKWebView Cookie 问题WKWebView Cookie 问题在于 WKWebView 发起的请求不会自动带上存储于 NSHTTPCookieStorage 容器中的 Cookie,而在 UIWebView 会自动带上 Cookie。原因是:WKWebView 拥有自己的私有存储,不会将 Cookie 存入到标准的 Cookie 容器NSHTTPCookieStorage 中。实践发现 WKWebView 实例其实也会将 Cookie 存储于 NSHTTPCookieStorage 中,但存储时机有延迟,在 iOS 8 上,当页面跳转的时候,当前页面的 Cookie 会写入 NSHTTPCookieStorage 中,而在 iOS 10 上,JS 执行 document.cookie 或服务器 set-cookie 注入的 Cookie 会很快同步到 NSHTTPCookieStorage 中,FireFox 工程师曾建议通过 reset WKProcessPool 来触发 Cookie 同步到 NSHTTPCookieStorage 中,实践发现不起作用,并可能会引发当前页面 session cookie 丢失等问题。解决办法1:WKWebView loadRequest 前,在 request header 中设置 Cookie, 解决首个请求 Cookie 带不上的问题;解决办法2:通过 document.cookie 设置 Cookie 解决后续页面(同域)Ajax``、iframe 请求的 Cookie 问题;(注意:document.cookie() 无法跨域设置 cookie)。3. WKWebView loadRequest 问题在 WKWebView 上通过 loadRequest 发起的 post 请求 body 数据会丢失,同样是由于进程间通信性能问题, HTTPBody 字段被丢弃。4. WKWebView NSURLProtocol问题WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在 WKWebView 上直接使用 NSURLProtocol 无法拦截请求。解决办法:由于 WKWebView 在独立进程里执行网络请求。一旦注册 http(s) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。在 webkit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC 发送给 App Process。出于性能的原因,encode 的时候 HTTPBody 和 HTTPBodyStream 这两个字段会被丢弃掉了。5. WKWebView 页面样式问题在 WKWebView 适配过程中,我们发现部分 H5 页面元素位置向下偏移或被拉伸变形,追踪后发现主要是 H5 页面高度值异常导致。解决办法:调整 WKWebView 布局方式,避免调整 webView.scrollView.contentInset 。实际上,即便在 UIWebView 上也不建议直接调整 webView.scrollView.contentInset 的值,这确实会带来一些奇怪的问题。如果某些特殊情况下非得调整 contentInset 不可的话,可以通过下面方式让H5页面恢复正常显示。6. WKWebView 截屏问题WKWebView 下通过 -[CALayer renderInContext:]实现截屏的方式失效,需要通过以下方式实现截屏功能:@implementation UIView (ImageSnapshot) - (UIImage*)imageSnapshot { UIGraphicsBeginImageContextWithOptions(self.bounds.size,YES,self.contentScaleFactor); [self drawViewHierarchyInRect:self.bounds afterScreenUpdates:YES]; UIImage* newImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return newImage; } @end然而这种方式依然解决不了 webGL 页面的截屏问题,截屏结果不是空白就是纯黑图片。解决办法:无奈之下,我们只能约定一个JS接口,让游戏开发商实现该接口,具体是通过 canvas getImageData()方法取得图片数据后返回 base64 格式的数据,客户端在需要截图的时候,调用这个JS接口获取base64 String并转换成 UIImage。7. WKWebView crash问题如果 WKWebView 退出的时候,JS刚好执行了 window.alert(), alert 框可能弹不出来,completionHandler 最后没有被执行,导致 crash;另一种情况是在 WKWebView 一打开,JS就执行 window.alert(),这个时候由于 WKWebView 所在的 UIViewController 出现( push 或 present )的动画尚未结束,alert 框可能弹不出来,completionHandler 最后没有被执行,导致 crash。8. 视频自动播放WKWebView 需要通过 WKWebViewConfiguration.mediaPlaybackRequiresUserAction 设置是否允许自动播放,但一定要在 WKWebView 初始化之前设置,在 WKWebView 初始化之后设置无效。9. goBack API问题WKWebView 上调用 -[WKWebView goBack], 回退到上一个页面后不会触发window.onload() 函数、不会执行JS。10. 页面滚动速率WKWebView 需要通过 scrollView delegate 调整滚动速率:- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { scrollView.decelerationRate = UIScrollViewDecelerationRateNormal; }三、Crosswalk 是什么,它有什么作用?参考网站: 《Crosswalk Github》 参考文章: 《Crosswalk入门》Crosswalk 是一款开源的 web 引擎。目前 Crosswalk 正式支持的移动操作系统包括 Android 和 Tizen ,在 Android 4.0 及以上的系统中使用 Crosswalk 的 Web 应用程序在 HTML5 方面可以有一致的体验,同时和系统的整合交互方面(比如启动画面、权限管理、应用切换、社交分享等等)可以做到类似原生应用。现在 Crosswalk 已经成为众多知名 HTML5 平台和应用的推荐引擎,包括 Google Mobile Chrome App 、 Intel XDK 、 Famo.us 和 Construct2 等等,未来的 Cordova 4.0 也计划集成 Crosswalk 。四、常见的 WebView 性能优化方案有哪一些?0. 问题分析首先需要了解,对于一个普通用户来讲,打开一个 WebView 通常会经历哪几个阶段,一般有这些:交互无反馈;到达新的页面,页面白屏;页面基本框架出现,但是没有数据;页面处于loading状态;出现所需的数据;当 App 首次打开时,默认是并不初始化浏览器内核的;只有当创建 WebView 实例的时候,才会创建 WebView 的基础框架。所以与浏览器不同,App 中打开 WebView 的第一步并不是建立连接,而是启动浏览器内核。于是我们找到了“为什么WebView总是很慢”的原因之一:在浏览器中,我们输入地址时(甚至在之前),浏览器就可以开始加载页面。而在客户端中,客户端需要先花费时间初始化 WebView 完成后,才开始加载。而这段时间,由于WebView还不存在,所有后续的过程是完全阻塞的。因此由于这段过程发生在 native 的代码中,单纯靠前端代码是无法优化的;大部分的方案都是前端和客户端协作完成,以下是几个业界采用过的方案:1. 全局 WebView在客户端刚启动时,就初始化一个全局的 WebView 待用,并隐藏,当用户访问了 WebView 时,直接使用这个 WebView 加载对应网页,并展示。这种方法可以比较有效的减少 WebView 在App中的首次打开时间。当用户访问页面时,不需要初始化 WebView 的时间。当然这也带来了一些问题,包括:额外的内存消耗。页面间跳转需要清空上一个页面的痕迹,更容易内存泄露。2. WebView 动态加载参考文章:《WebView常用优化方案》WebView 动态加载。就是不在 xml 中写 WebView ,写一个 layout ,然后把 WebView add 进去。WebView mWebView = new WebView(getApplicationgContext()); LinearLayout mll = findViewById(R.id.xxx); mll.addView(mWebView); protected void onDestroy() { super.onDestroy(); mWebView.removeAllViews(); mWebView.destroy() }这里用的 getApplicationContext() 也是防止内存溢出,这种方法有一个问题。如果你需要在 WebView 中打开链接或者你打开的页面带有 flash,获得你的 WebView 想弹出一个 dialog ,都会导致从 ApplicationContext 到 ActivityContext 的强制类型转换错误,从而导致你应用崩溃。这是因为在加载 flash 的时候,系统会首先把你的 WebView 作为父控件,然后在该控件上绘制 flash ,他想找一个 Activity 的 Context 来绘制他,但是你传入的是 ApplicationContext 。然后就崩溃了。3. 独立的web进程,与主进程隔开参考文章:《WebView常用优化方案》这个方法被运用于类似 qq ,微信这样的超级 app 中,这也是解决任何 WebView 内存问题屡试不爽的方法 对于封装的 webactivity ,在 manifest.xml 中设置。<activity android:name=".webview.WebViewActivity" android:launchMode="singleTop" android:process=":remote" android:screenOrientation="unspecified" />然后在关闭 webactivity 时销毁进程:@Overrideprotected void onDestroy() { super.onDestroy(); System.exit(0); }关闭浏览器后便销毁整个进程,这样一般 95% 的情况下不会造成内存泄漏之类的问题,但这就涉及到 android 进程间通讯,比较不方便处理,优劣参半,也是可选的一个方案。4. WebView 释放参考文章:《WebView常用优化方案》public void destroy() { if (mWebView != null) { // 如果先调用destroy()方法,则会命中if (isDestroyed()) return;这一行代码,需要先onDetachedFromWindow(),再 // destory() ViewParent parent = mWebView.getParent(); if (parent != null) { ((ViewGroup) parent).removeView(mWebView); } mWebView.stopLoading(); // 退出时调用此方法,移除绑定的服务,否则某些特定系统会报错 mWebView.getSettings().setJavaScriptEnabled(false); mWebView.clearHistory(); mWebView.clearView(); mWebView.removeAllViews(); try { mWebView.destroy(); } catch (Throwable ex) { } } }
四、什么是 JS Bridge,它的作用是什么参考文章:《JSBridge的原理》4.1 JS Bridge 介绍JSBridge 简单来讲,主要是 给 JavaScript 提供调用 Native 功能的接口,让混合开发中的前端部分可以方便地使用地址位置、摄像头甚至支付等 Native 功能。JSBridge 就像其名称中的 “Bridge” 的意义一样,是 Native 和非 Native 之间的桥梁,它的核心是 构建 Native 和非 Native 间消息通信的通道,而且是 双向通信的通道。JSBridge 另一个叫法及大家熟知的 Hybrid app 技术。所谓 双向通信的通道:JS 向 Native 发送消息 :调用相关功能、通知 Native 当前 JS 的相关状态等。Native 向 JS 发送消息 :回溯调用结果、消息推送、通知 JS 当前 Native 的状态等。4.2. JS Bridge 实现原理参考文章:《Hybrid APP基础篇(四)->JSBridge的原理》Android 和 iOS 的 JSBridge 实现方式:4.2.1 基本流程H5 页面通过某种方式触发一个 url scheme ;Native 捕获到 url scheme,并进行分析和处理;Native 调用 H5 的 JSBridge 对象传递回调;原生的 WebView/UIWebView 控件已经能够和 JS 实现数据通信了,那为什么还要 JSBridge呢?其实使用JSBridge有很多方面的考虑:Android4.2以下,addJavascriptInterface 方式有安全漏掉。iOS7以下,JS 无法调用 Native。url scheme 交互方式是一套现有的成熟方案,可以完美兼容各种版本,对以前老版本技术的兼容。4.2.1 实现流程(Android 为例)拟定协议,参考 http 制定的协议为:jsbridge://className:port/methodName?jsonObj;className // Android端实现暴露给前端的类名 port // Android返回结果给前端的端口 methodName // 前端需要调用的函数 jsonObj // 前端给Android传递的参数新建 HTML 文件命名为 index.html, 编写一个 button 绑定 click 事件;<button onclick="JSBridge.call( 'bridge', 'showToast', {'msg':'Hello JSBridge'}, function(res){ alert(JSON.stringify(res)) } )"> 测试showToast </button>新建 JS 文件命名为 JSBridge.js, 第2步中的 JSBridge.call 即为调用 JSBridge.js 中的 call 方法,后面带了四个参数;call: function (obj, method, params, callback) { console.log(obj+" "+method+" "+params+" "+callback); var port = Util.getPort(); console.log(port); this.callbacks[port] = callback; var uri=Util.getUri(obj,method,params,port); console.log(uri); window.prompt(uri, ""); },JSBridge.js 中的 call 方法,最后调用了 window.prompt 方法,这个方法就是触发 Android 端 webChromeClient 的回调函数用的。window.prompt 触发了 WebChromeClient(这个需要使用函数WebView.setWebChromeClient( new WebChromeClietn() )进行设定);类中的如下回调 onJsPrompt。这时就完成了前端与 Android端 的通信了,因为前端的信息都顺利通过这个函数传递给Android了。@Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { result.confirm(JSBridge.callJava(view,message)); return true; }Android 中会定义一个类 JSBridge.java 来管理暴露给前端使用的函数;这个类有两个功能:暴露给前端的函数的动态注册功能。解析前端信息,调用了 Android 端对应的函数,这个示例中是:showToast 函数。解析前端的信息,获取前端调用的函数名:Uri uri = Uri.parse(uriString); className = uri.getHost(); param = uri.getQuery(); port = uri.getPort() + ""; String path = uri.getPath(); HashMap< String, Method> methodHashMap = exposedMethod.get(className); Method method = methodHashMap.get(methodName);通过获取的函数名,这里是 showToast ,调用 Android 端的 showToast 函数。method.invoke(null,webView,new JSONObject(param),new Callback(webView,port));定义类 BridgeImpl.java 来具体的实现暴露给前端的所有函数。这里的 showToast 函数如下:public static void showToast(WebView webView, JSONObject param, final JSBridge.Callback callback){ String message = param.optString("msg"); Toast.makeText(webView.getContext(),message,Toast.LENGTH_LONG).show(); if(null != callback){ try { JSONObject object = new JSONObject(); object.put("key","value"); object.put("key1","vaule1"); callback.apply(getJSONObject(0,"ok",object)); }catch (Exception e){ e.printStackTrace(); } } }五、请列举 Android 与 iOS 平台下 JS Bridge 的实现方式这边代码比较多,我使用图片来展示,大家可以放大来查看。5.1 Android 实现方式5.1.1 Android 调用 JS 的 2 种方式通过 WebView 的 loadUrl():JS 代码调用一定要在 onPageFinished() 回调之后才能调用,否则不会调用。Web 端代码:<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>前端代码</title> <script> // Android需要调用的方法 function callJS(){ alert("Android调用了JS的callJS方法"); } </script> </head> </html>Android 端代码:通过 WebView 的 evaluateJavascript():// 只需要将第一种方法的loadUrl()换成下面该方法即可 mWebView.evaluateJavascript( "javascript:callJS()", new ValueCallback<String>() { @Override public void onReceiveValue(String value) { //此处为 js 返回的结果 } }); }5.1.2 JS 调用 Android 的 3 种方式通过 WebView 的 addJavascriptInterface() 进行对象映射:Android 映射:// 继承自Object类 public class AndroidtoJs extends Object { // 定义JS需要调用的方法 // 被JS调用的方法必须加入@JavascriptInterface注解 @JavascriptInterface public void hello(String msg) { System.out.println("JS调用了Android的hello方法"); } }Web:<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>前端代码</title> <script> function callAndroid(){ // 由于对象映射,所以调用test对象等于调用Android映射的对象 test.hello("js调用了android中的hello方法"); } </script> </head> <body> //点击按钮则调用callAndroid函数 <button type="button" id="button1" "callAndroid()"></button> </body> </html>Android 端:通过 WebViewClient 的 shouldOverrideUrlLoading () 方法回调拦截 url:Web 端:<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>前端代码</title> <script> function callAndroid(){ /*约定的url协议为:js://webview?arg1=111&arg2=222*/ document.location = "js://webview?arg1=111&arg2=222"; } </script> </head> <!-- 点击按钮则调用callAndroid()方法 --> <body> <button type="button" id="button1" onclick="callAndroid()" >点击调用Android代码</button> </body> </html>Android 端:通过 WebChromeClient 的方法回调拦截JS对话框方法:通过 WebChromeClient 的 onJsAlert() 、onJsConfirm() 、onJsPrompt()方法回调拦截JS对话框 alert() 、confirm()、prompt() 消息。Web 端:<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>前端代码</title> <script> function clickprompt(){ // 调用prompt() var result=prompt("js://demo?arg1=111&arg2=222"); alert("demo " + result); } </script> </head> <!-- 点击按钮则调用clickprompt() --> <body> <button type="button" id="button1" onclick="clickprompt()" >点击调用Android代码</button> </body> </html>Android 端:5.2 iOS 实现方式5.2.1 JS 调用 iOS 的 2 种方式使用 XMLHttpRequest 发起请求的方式:Web 端:XMLHttpRequest bridge:JS 端使用 XMLHttpRequest 发起了一个请求:execXhr.open('HEAD', "/!gap_exec?" + (+new Date()), true);,请求的地址是 /!gap_exec;并把请求的数据放在了请求的 header 里面,见这句代码:execXhr.setRequestHeader('cmds', iOSExec.nativeFetchMessages());。而在 Objective-C 端使用一个 NSURLProtocol 的子类来检查每个请求,如果地址是 /!gap_exec 的话,则认为是 Cordova 通信的请求,直接拦截,拦截后就可以通过分析请求的数据,分发到不同的插件类(CDVPlugin 类的子类)的方法中:Cordova 中优先使用这种方式, Cordova.js 中的注释有提及为什么优先使用 XMLHttpRequest 的方式,及为什么保留第二种 iframe bridge 的通信方式:// XHR mode does not work on iOS 4.2, so default to IFRAME_NAV for such devices. // XHR mode’s main advantage is working around a bug in -webkit-scroll, which // doesn’t exist in 4.X devices anyways123iframe bridge:在 JS 端创建一个透明的 iframe,设置这个 ifame 的 src 为自定义的协议,而 ifame 的 src 更改时,UIWebView 会先回调其 delegate 的 webView:shouldStartLoadWithRequest:navigationType: 方法,关键代码如下:通过设置透明的 iframe 的 src 属性:5.2.2 iOS 调用 JS 的方式UIWebView 有一个这样的方法 stringByEvaluatingJavaScriptFromString:,这个方法可以让一个 UIWebView 对象执行一段 JS 代码,这样就可以达到 Objective-C 跟 JS 通信的效果,在 Cordova 的代码中多处用到了这个方法,其中最重要的两处如下:获取 JS 的请求数据:把 JS 请求的结果返回给 JS 端:结语对于初入混合应用开发的小伙伴,这些会有点难度,但是好好理解下那几张流程图,再理一理思路,相信会有帮助😁给大家加加油~~
前言我们大前端团队内部 📖每周一练 的知识复习计划还在继续,本周主题是 《Hybrid APP 混合应用专题》 ,这期内容比较多,篇幅也相对较长,每个知识点内容也比较多。之前分享的每周内容,我都整理到掘金收藏集 📔《EFT每周一练》 上啦,欢迎点赞收藏咯💕💕。注:本文整理资料来源网络,有些图片/段落找不到原文出处,如有侵权,联系删除。一、什么是 Hybrid App,与 Native App 及 Web App 有什么区别参考文章:《Web App Hybrid App和 Native App的区别》《Hybrid APP基础篇(二) -> Native、Hybrid、React Native、Web App方案的分析比较》1.1 主流应用类型随着现在移动互联网的快速发展,市面上目前主流移动应用程序主要分三类:Web App、 Native App 和 Hybrid App。三者大致关系如下:1.2 Web AppWeb App,即移动端网站,一般指的是基于 Web 的应用,基于浏览器运行,无需下载安装,基本上可以说是触屏版的网页应用。这类应用基本上是一个网页或一系列网页,旨在在移动屏幕上工作。Web 网站一般分为两种:MPA(Multi-page Application)SPA(Single-page Application)一般的 Web App 是指 SPA 形式开发的网站。优点:开发和维护成本低,可以跨平台,调试方便;前端人员开发的代码,可应用于各大主流浏览器(特殊情况可以代码进行下兼容),没有新的学习成本,而且可以直接在浏览器中调试。更新最为快速;由于web app资源是直接部署在服务器端的,所以只需替换服务器端文件,用户访问是就已经更新了(当然需要解决一些缓存问题)。无需安装App,不会占用手机内存;通过浏览器即可访问,无需安装,用户使用成本更低。缺点:性能低,用户体验差;由于是直接通过的浏览器访问,所以无法使用原生的API,操作体验不好。依赖于网络,页面访问速度慢,耗费流量;Web App每次访问都必须依赖网络,从服务端加载资源,当网速慢时访问速度很不理想,特别是在移动端,对网站性能优化要求比较高。功能受限,大量功能无法实现;只能使用 HTML5 的一些特殊 API ,无法调用原生 API ,所以很多功能存在无法实现情况。临时性入口,用户留存率低;这既是它的优点,也是缺点,优点是无需安装,确定是用完后有时候很难再找到,或者说很难专门为某个web app留存一个入口,导致用户很难再次使用。1.3 Native AppNative APP 指的是原生程序,需要用户下载安装使用,一般依托于操作系统,有很强的交互,是一个完整的App,可拓展性强,能发布应用商店。目前市面上主流的平台有:Android 和 iOS。优点:直接依托于操作系统,用户体验好,操作流畅,性能稳定;用户留存率高;功能最为强大,特别是在与系统交互中,几乎所有功能都能实现;由于 Native APP 是直接依托于系统,所以可以直接调用官方提供的API,功能最为全面(比如本地资源操作,通知,动画等)。缺点:开发和维护成本高,无法跨平台,需要各平台各自独立开发;Android 上基于 Java 开发,iOS 上基 OC 或 Swift 开发,相互之间独立,必须要有各自的开发人员。门槛较高,原生人员有一定的入门门槛,人才较少;原生的一个很大特点就是独立,所以不太容易入门,而且 Android, iOS都需要独立学习。分发成本高,更新缓慢,特别是发布应用商店后,需要等到审核周期;原生应用更新是一个很大的问题, Android中还能直接下载整包APK进行更新,但是 iOS中,如果是发布 AppStore ,必须通过 AppStore地址更新,而每次更新都需要审核,所以无法达到及时更新。1.4 Hybrid AppHybrid App 指的是混合开发,也就是半原生半 Web 的开发模式,有跨平台效果,当然了,实质最终发布的仍然是独立的原生APP(各种的平台有各种的SDK)。优点:学习和开发成本较低,可以跨平台,调试方便;Hybrid 开发模式下,由原生提供统一的 API 给 JS 调用,实际的主要逻辑由 HTML 和 JS 完成,最终放在 webview 中显示,这样只需要写一套代码即可,达到跨平台效果,另外也可以直接在浏览器中调试,很方便。一般 Hybrid 中的跨平台最少可以跨三个平台: Android App ,iOS App ,普通 webkit 浏览器。需要前端人员关注一些原生提供的API,具体的实现无需关心,没有新的学习内容。维护成本低,功能可复用,并且更容易更新;虽然没有 web app 更新那么快速,但是 Hybrid 中也可以通过原生提供 api ,进行资源主动下载,达到只更新资源文件,不更新 apk(ipa) 的效果。功能更加完善,性能和体验要比起 web app 好太多;因为可以调用原生api,所以很多功能只要原生提供出就可以实现,另外性能也比较接近原生。部分性能要求的页面可用原生实现;这种模式是原生混合 web ,所以我们完全可以将交互强,性能要求高的页面用原生写,然后一些其它页面用 JS 写,嵌入 webview 中,达到最佳体验。缺点:相比原生,性能仍然有较大损耗;这种模式受限于 webview 的性能,相比原生而言有不少损耗,体验无法和原生相比。不适用于交互性较强的app;这种模式的主要适用:一些新闻阅读类,信息展示类的 app ,不适用于一些交互较强或者性能要求较高的 app (比如动画较多就不适合)。1.5 三者区别三者使用场景对比:三者技术特征对比:另外增加 ReactNative 一起放入作对比。NativeAppWebAppHybridAppReactNativeApp原生功能体验优秀差良好接近优秀渲染性能非常快慢接近快快是否支持设备底层访问支持不支持支持支持网络要求支持离线依赖网络支持离线(资源存本地情况)支持离线更新复杂度高(几乎总是通过应用商店更新)低(服务器端直接更新)较低(可以进行资源包更新)较低(可以进行资源包更新)编程语言Android(Java),iOS(OC/Swift)js+html+css3js+html+css3主要使用JS编写,语法规则JSX社区资源丰富(Android,iOS单独学习)丰富(大量前端资源)有局限(不同的Hybrid相互独立)丰富(统一的活跃社区)上手难度难(不同平台需要单独学习)简单(写一次,支持不同平台访问)简单(写一次,运行任何平台)中等(学习一次,写任何平台)开发周期长短较短中等开发成本昂贵便宜较为便宜中等跨平台不跨平台所有H5浏览器Android,iOS,h5浏览器Android,iOSAPP发布AppStoreWeb服务器AppStoreAppStore1.6 三者如何选择这里简单介绍几种情况,具体还是要以实际项目技术评估结果为主。选择纯 Native App 模式的情况:性能要求极高,体验要求极好,不追求开发效率。选择 Web App 模式的情况:不追求用户体验和性能,对离线访问没要求,正常来说,如果追求性能和体验,都不会选用web app。选择 Hybrid App 模式的情况大部分情况下的App都推荐采用这种模式,这种模式可以用原生来实现要求高的界面,对于一些比较通用型,展示型的页面完全可以用web来实现,达到跨平台效果,提升效率。一般好一点的Hybrid方案,都会把资源放在本地的,可以减少网络流量消耗。选择React Native App模式的情况追求性能,体验,同时追求开发效率,而且有一定的技术资本,舍得前期投入。React Native这种模式学习成本较高,所以需要前期投入不少时间才能达到较好水平,但是有了一定水准后,开发起来它的优势就体现出来了,性能不逊色原生,而且开发速度也很快二、什么是 Cordova,它的优缺点是什么参考文章: 《浅谈Cordova框架》2.1 Cordova 简介Cordova 是一个用基于 HTML、CSS 和 JavaScript 的,用于创建跨平台移动应用程序的快速开发平台。它使开发者能够利用iPhone、Android、Palm、Symbian、WP7、Bada和Blackberry等智能手机的核心功能——包括地理定位、加速器、联系人、声音和振动等,此外 Cordova 拥有丰富的插件,可以调用。也可以用来开发原生和WebView组件之间的插件接口。来源:Cordova 是 PhoneGap 贡献给 Apache 后的开源项目,是从 PhoneGap 中抽出的核心代码,是驱动 PhoneGap 的核心引擎。可以把它们的关系想象成类似于 Webkit 和 Google Chrome 的关系。2.2 Cordova 架构图架构图介绍:Web App用于存放我们程序的代码,包括业务逻辑,还有一些运行需要的资源(如:CSS,JavaScript,图片,媒体文件等)。 应用的实现是通过 web 页面,默认的本地文件名称是 index.html ,应用执行在原生应用包装的 WebView 中,这个原生应用是你分发到应用商店中的。WebViewCordova 用的 WebView 可以给应用提供完整用户访问界面,使得应用混合了 Webview 和原生的应用组件。Cordova Plugins插件是 Cordova 生态系统的重要组成部分。它提供了 Cordova 和原生组件相互通信的接口,并绑定到了标准的设备API上,这使你能够通过 JavaScript 调用原生代码。2.3 优缺点优点:跨平台,开发简单,学习成本低;框架多,插件多,可自定义插件;发展最早,社区资源丰富;缺点:WebView性能低下时,用户体验差,反应慢;中文文档资源少;调试不方便,既不像原生那么好调试,也不像纯web那种调试;三、Cordova 插件的原理是什么Cordova 插件就是一些附加代码用来提供原生组件的 JavaScript 接口,它允许你的 App 可以使用原生设备的能力,超越了纯粹的 Web App。Cordova 在 iOS 上的实现原理: 3.1 工作流程Cordova 发起对原生的请求:cordova.exec(successCallback, failCallback, service, action, actionArgs); // successCallback: 成功回调方法 // failCallback: 失败回调方法 // server: 所要请求的服务名字 // action: 所要请求的服务具体操作 // actionArgs: 请求操作所带的参数这五个参数并不是直接传给原生,Cordova JS 端会做以下处理:为每个请求生成一个唯一标识( callbackId ),并传给原生端,原生端处理完后,会把 callbackId 连同处理结果一起返回给 JS 端;以 callbackId 为 key,{success:successCallback, fail:failCallback} 为 value,把这个键值对保存在 JS 端的字典里,successCallback 与 failCallback 这两个参数不需要传给原生,原生返回结果时带上 callbackId,JS 端就可以根据 callbackId 找到回调方法;每次 JS 请求,最后发到原生的数据包括:callbackId, service, action, actionArgs;原生代码拿到callbackId、service、action及actionArgs后,会做以下处理:根据 service 参数找到对应插件类;根据 action 参数找到插件类中对应的处理方法,并把 actionArgs 作为处理方法请求参数的一部分传给处理方法;处理完成后,把处理结果及 callbackId 返回给 JS 端,JS 端收到后会根据 callbackId 找到回调方法,并把处理结果传给回调方法;JS 端根据 callbackId 回调 cordova.js// 根据 callbackId 及是否成功标识,找到回调方法,并把处理结果传给回调方法 callbackFromNative: function(callbackId, success, status, args, keepCallback) { var callback = cordova.callbacks[callbackId]; if (callback) { if (success && status == cordova.callbackStatus.OK) { callback.success && callback.success.apply(null, args); } else if (!success) { callback.fail && callback.fail.apply(null, args); } // Clear callback if not expecting any more results if (!keepCallback) { delete cordova.callbacks[callbackId]; } } }
2022年08月
2022年05月