JavaScript系列干货-下

532 阅读23分钟

1,手写符合PromiseA+规范的Promise

const PENDING = 'pending', FULFILLED = 'fulfilled', REJECTED = 'rejected';
// 实现我们的Promise
class MyPromise {
    constructor(excutor) {
        this.state = PENDING
        this.value = undefined
        this.reason = undefined
        this.resolveList = []
        this.rejectList = []
        // resolve将Promise状态变成成功,保存成功值
        const resolve = value => {
            if (this.state === PENDING) {
                this.state = FULFILLED
                this.value = value
                this.resolveList.forEach(fn => fn())
            }
        }
        // reject将Promise状态变为失败,保存失败值
        const reject = reason => {
            if (this.state === PENDING) {
                this.state = REJECTED
                this.reason = reason
                this.rejectList.forEach(fn => fn())
            }
        }
        try {
            excutor(resolve, reject)
        } catch (error) {
            reject(error)
        }
    }
    then(resolveFn, rejectFn) {
        resolveFn = typeof resolveFn === 'function' ? resolveFn : value => value
        rejectFn = typeof rejectFn === 'function' ? rejectFn : err => { throw err }
        let bridgePromise
        return bridgePromise = new MyPromise((resolve, reject) => {
            if (this.state === FULFILLED) {
                setTimeout(() => {
                    try {
                        const x = resolveFn(this.value)
                        resolvePromise(bridgePromise, x, resolve, reject)
                    } catch (error) {
                        reject(error)
                    }
                }, 0);
            }
            if (this.state === REJECTED) {
                setTimeout(() => {
                    try {
                        const x = rejectFn(this.reason)
                        resolvePromise(bridgePromise, x, resolve, reject)
                    } catch (error) {
                        reject(error)
                    }
                }, 0);
            }
            if (this.state === PENDING) {
                this.resolveList.push(_ => {
                    setTimeout(() => {
                        try {
                            const x = resolveFn(this.value)
                            resolvePromise(bridgePromise, x, resolve, reject)
                        } catch (error) {
                            reject(error)
                        }
                    }, 0);
                })
                this.rejectList.push(_ => {
                    setTimeout(() => {
                        try {
                            const x = rejectFn(this.reason)
                            resolvePromise(bridgePromise, x, resolve, reject)
                        } catch (error) {
                            reject(error)
                        }
                    }, 0);
                })
            }
        })
    }
}
// 处理x,如果x是Promise/thenable对象需要递归获取最终值与最终状态,给bridePromise
function resolvePromise(bridgePromise, x, resolve, reject) {
    if (bridgePromise === x) return reject(new TypeError('circle error')); // 防止x与bridgePromise互相引用
    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
        let called = false
        try {
            const then = x.then
            if (typeof then === 'function') {
                then.call(x,
                    y => {
                        if (called) return
                        called = true
                        resolvePromise(bridgePromise, y, resolve, reject)
                    },
                    err => {
                        if (called) return
                        called = true
                        reject(err)
                    }
                )
            } else {
                resolve(x)
            }
        } catch (error) {
            if (called) return
            called = true
            reject(error)
        }
    } else {
        resolve(x)
    }
}


// 判断当前Promise是否符合PromiseA+需要用到的代码(注意需要安装相关包promises-aplus-tests ) 执行命令: promises-aplus-tests demo10.js (当前文件名:demo10.js)
MyPromise.deferred = function () {
    let defer = {};
    defer.promise = new MyPromise((resolve, reject) => {
        defer.resolve = resolve;
        defer.reject = reject;
    });
    return defer;
}
try {
    module.exports = MyPromise
} catch (e) { }

2,JS类型转换

  • 显式类型转换:

    Number()String()Boolean()
    undefinedNaN直接加上引号'undefined'false
    null0直接加上引号'null'false
    number不变+0与-0转为'0',其他直接加上引号+0与-0与NaN都转成false,其他为true
    string1,字符串''转为0, 2,字符串不管首尾有没有空格字符,只要中间字符串全为数字组成,那么直接返回该数字('123'=>123;' 123 '=>123),其他全部返回NaN1 1=>NaN;1i1=>NaN不变''转成false,其他全为true
    booleantrue转为1,false转为0直接加上引号'false','true'不变
    object(引用类型)转换为toPramitive(object,'number')返回值转换为toPramitive(object,'string')返回值全为true
    • toPramitive:顾名思义这是一个将引用类型转换成原始值的函数,第一个参数是需要进行转换的数据,第二个参数是偏向性,即偏向于'number'还是偏向于'string',其实就是根据第二个参数判断先调用valueOf还是toString,下面是该函数的伪代码实现
          function toPrimitive(object, direction) {
              // 1,当偏向性是'number'
              if (direction === 'number') {
                  // 1.1,先调用object.valueOf(),返回值是基本类型则直接返回该值
                  if (object.valueOf() === '基本类型') return object.valueOf()
                  // 1.2,如果object.valueOf()返回值非基本类型,那么调用object.toString(),返回值为基本类型则返回该值
                  if (object.toString() === '基本类型') return object.toString()
                  // 1.3,object.toString()与object.valueOf()返回值都不是基本类型,则抛出错误 new TypeError('Cannot convert object to primitive value')
                  throw new TypeError('Cannot convert object to primitive value')
              }
              // 2,当偏向性是'string'
              if (direction === 'string') {
                  // 1.1,先调用object.toString(),返回值是基本类型则直接返回该值
                  if (object.toString() === '基本类型') return object.toString()
                  // 1.2,如果object.toString()返回值非基本类型,那么调用object.valueOf(),返回值为基本类型则返回该值
                  if (object.valueOf() === '基本类型') return object.valueOf()
                  // 1.3,object.toString()与object.valueOf()返回值都不是基本类型,则抛出错误 new TypeError('Cannot convert object to primitive value')
                  throw new TypeError('Cannot convert object to primitive value')
              }
              // 3, object.toString 与 object.valueOf 在没有重写的情况下一般返回什么值
              // Object.toString: 返回原对象的字符串类型,相当于返回 Object.prototype.toString.call(object)
              // Object.valueOf: 返回原对象
              
              // 4,注意数组的toString返回的是数组元素字符串,比如
              // [].toString=>''
              // [1,2,3].toString()=>'1,2,3'
          }
      
  • 隐式类型转换:发生冲突时才会出现隐式类型转换,这里的冲突分两种数据之间的冲突数据与运算符之间的冲突,当出现冲突时,数据就可能会调用显示类型转换中的Number(),String(),Boolean()以及toPrimitive()进行类型转换,所以我们需要知到数据在什么时候调用哪种显示类型转换方式,而下面的偏向性确定就是帮助我们做这件事。

    • 偏向性:当发生隐式类型转换时,我们有个偏向性确定,即确定当前表达式期望以什么数据类型方式去计算, 比如`I'm ${18} years old.`,这里我们很明显期望输出字符串,所以偏向性为string,所以除去引用类型,其他基本类型使用string进行强转即可得到返回值,而引用类型我们期望得到string类型值,所以我们调用toPrimitive第二个参数就要传入string,即优先调用当前引用类型toString方法来获得引用类型原始值。

    • 不同场景下的偏向性确定:隐式类型转换个人看来就是所谓的偏向性的确定,偏向什么数据类型就会将该数据隐式转成当前数据类型,下面的表格输出不同场景下的偏向性。

      出现隐式类型转换的场景偏向性加深记忆
      模板字符串``string对于模板字符串我们显而易见更倾向于获得字符串
      位运算number对于位运算,我们肯定更希望变成数字类型更方便
      逻辑运算!boolean逻辑运算显然就是判断是否,所以我们更期望获得布尔类型
      四则运算(排除+)number四则运算当然期望变成数字类型进行运算
      === 与 !==值引用地址精确等于与精确不等于,为了更精确判断我们当然是根据引用地址判断是否相等或不等
      +只要+任意一侧出现字符类型,则偏向string,否则偏向number如果+两侧存在字符类型,那么我们期望的是拼接字符串,所以此时+运算的偏向性为string很合理,除此之外,偏向性均为number,因为除了拼接字符串我们就是期望数值类型进行计算。
      比较运算(排除==与!==)比较运算两侧都是字符类型,则偏向性string,否则偏向number当比较运算符两侧均为字符类型,我们可以很容易根据ASCII编码比较其大小,所以string就足够比较大小了,当然除去两边都是字符类型的其他情况,我们肯定更希望都转成数字类型比较大小更方便
      == 与 !==)①:(null|undefined) == (null|undefined);②:(null|undefined) !== 其他任何类型);③:如果当前符号两侧同类型,那么偏向性位引用地址;④:符号两侧既不是同类型也不是null/undefined则偏向性为numbernull 与 undefined 死记,同类型很好理解,类型相同我们比较地址就可以了,所以偏向性位值引用地址,不同类型又不是undefined/null,那么比较其是否相等我们更期望数字类型进行比较,所以偏向性为number
    • 注意如果表达式中出现了引用类型,我们需要先将引用类型根据偏向性计算得出基本类型,再继续根据当前两个基本类型继续算出最终偏向性,比如1+{},我们偏向性为number,所以1+{}=>1+toPrimitive({},'number')=>1+'[object Object]',此时+号两边都是基本类型,继续判断偏向性为string,所以1+'[object Object]' => '1' +'[object Object]' = '1[object Object]'

    • 有了偏向性与偏向性确定的表格,我们就可以根据表格偏向性确定当前表达式如何进行隐式转换。看下面的例子以及解析。

      表达式偏向性根据偏向性隐式转换过程结果
      `I'm ${ {toString(){ return 12 }} } years old.`stringtoPrimitive({toString(){ return 12 }},'string' )I'm 12 yars old.
      !{}boolean!Boolean({})=>!true=>falsefalse
      '12'-'a'number12-NaN=>NaNNaN
      null === {}值引用地址0000 === 0011false
      '1' + {}string'1'+{}.toString()=>'1'+'[object Object]'=>'1[object Object]''1[object Object]'
      '1' + [1,2]string'1'+[1,2].toString()=>'1'+'1,2'=>'11,2''11,2'
      false + 0number0 + 00
      false + truenumber0 + 11
      false + []number stringfalse + toPrimitive([],'number')=>false+''=>'false'+''=>'false''false'
      '123' == '123'string'123'=='123'true
      '123' == 123number123==123true
      '123' == {}number'123'== toPrimitive({},'number')=>'123'== '[object Object]'false
      false == truenumber0 ==1 => falsefalse
      null == undefined相等相等true
      null == 0不相等不相等false
      undefined == []不相等不相等false
      NaN == 0numberNaN与任何数字类型作比较包括它自己,都返回falsefalse
      'a' > 'c'string,比较双方asciifalsefalse
      'a' > 1numberNaN > 1false
      '12' > '1'number12 > 1true

3,前端路由中的hash模式与history模式

  • 单页面应用(SPA): 传统页面一般一个url对应一个服务器页面资源,当url发生变化时,浏览器将向服务器请求当前url的资源页面,所以传统页面一般是多页面应用。而单页面应用自始至终只有一个页面,当url发生变化时,并不向服务器请求新的页面资源,而是根据当前url用JS去展示对应的内容。 下面两张图可以很好的理解传统页面与SPA区别。 image.png

    image.png

  • 前端路由: 前端路由分为两种hash路由history路由,前端路由主要就是解决单页面应用面临的问题,即当url发生变化,不向服务器发起新的资源请求,仅使用js根据当前url展示对应内容。

  • 前端路由实现思路: 其实只需要解决以下两个问题就可以实现前端路由:

    • 1,当url发生变化,浏览器不会向服务器发起新的页面资源请求。

    • 2,能够监听到每一种url的变化,并且在url变化后展示对应url需要展示的内容。 所以这里我们统计一下能够引起url变化的几种场景,如下:

      • 1,手动键入url

      • 2,点击浏览器的前进后退按钮

      • 3,使用js改变url

      • 4,a标签的href属性改变url

  • hash模式实现原理: hash模式中的hash就是www.abc.com#page1/page2中的#以及后面的字符串#page1/page2且url中的hash的变化不会引起浏览器重新加载新的页面资源。其次浏览器提供了hash变化的监听事件hashchange,且hashchange事件对上面4种url变化场景都能监听到。 所以hash的这两个特性就非常适合用来实现前端路由。下面是实现hash路由中将要用到的几个简单的hash相关方法简介。

    • 获取hash:var hash = window.location.hash 即可获取到当前页面的hash

    • 设置hash:window.location.hash = '#xxx' 即可设置页面的hash

    • 监听hash变化:window.addEventListener('hashChange',callBack)即可监听到hash的变化

  • hash模式的简单实现

    <!DOCTYPE html>
    <html lang="en">
    
    <body>
        <div id="root"></div>
        <script>
            // 极简hash路由
            class HashRouter {
                constructor(routes) {
                    // 1,保存注册的路由
                    this.routes = routes
                    // 2,监听load事件与hashchange事件(load事件用于首次加载页面展示对应路由,hashchange不能监听到首次加载页面)
                    window.addEventListener('load', this.update)
                    window.addEventListener('hashchange', this.update)
                }
                // 3,页面hash值发生变化,则执行对应hash的js代码,展示对应的页面内容
                update = () => {
                    const hash = !location.hash ? '/' : location.hash.slice(1)
                    this.routes[hash] && this.routes[hash]()
                }
    
            }
            new HashRouter({
                '/': () => root.innerHTML = '首页',
                'a': () => root.innerHTML = 'a页面'
            })
    
        </script>
    </body>
    
    </html>
    
  • history模式:H5后浏览器history对象提供了history.pushState(保留会话历史记录同时,将新的url加入到历史中)与history.replaceState(会将当前页面历史替换成指定url),它们可以改变url同时不会刷新页面。所以我们在进行页面跳转的时候使用history.pushState即可更改url,随后执行对应的更新SPA页面内容函数即可。

  • history模式四种导致页面发生跳转行为对应处理方法:

    • 1,浏览器前进回退按钮点击:这里使用window.addEventListener('popstate',callback)即可对其进行监听,随后执行对应的更新SPA页面内容函数。

    • 2,a标签默认行为<a href='localhost:8080/page2'></a>:劫持所有点击事件,如果是目标元素是a标签,同时href有效,则阻止默认事件,替换成手动更新history,随后执行对应的更新SPA页面内容函数。

    • 3,如果是js行为跳转,那么手动手动更新history,随后执行对应的更新SPA页面内容函数。

    • 4,针对于手动修改浏览器导航栏路径进入页面,history模式会获取新资源,所以对于这种情况,需要在服务器端做拦截,其他路径都返回当前SPA页面即可

    • ps:history模式需要部署在服务器上使用下图代码中相对路径没问题,直接访问本地文件需要使用绝对路径,所以下面代码建议放在服务器静态资源中访问,保证一下跑起来**

      <!DOCTYPE html>
      <html lang="en">
      
      <body>
          <div id="root"></div>
          <script>
              // 极简history路由
              class HashRouter {
                  constructor(routes) {
                      // 1,保存注册的路由
                      this.routes = routes
                      // 2,监听load事件更新页面内容
                      window.addEventListener('load', this.update)
                      // 3,监听浏览器前进后退,history.go,history.back,history.foward,更新页面内容
                      window.addEventListener('popstate', this.update)
                      // 4,重写history.pushState与history.replaceState,保证使用二者时可以更新页面内容(popState不能监听到二者)
                      this.rewrite('pushState', 'replaceState')
                  }
                  rewrite = (...types) => {
                      types.forEach(type => {
                          const operateState = history[type]
                          const update = this.update
                          history[type] = function (...args) {
                              // 5,执行对应的pushState/replaceState
                              operateState.call(this, ...args)
                              // 6,执行对应页面内容更新
                              update()
                          }
                      })
                  }
                  // 7,页面hash值发生变化,则执行对应hash的js代码,展示对应的页面内容
                  update = () => {
                      const path = !this.routes[location.pathname] ? '/' : location.pathname
                      this.routes[path]()
                  }
      
              }
              new HashRouter({
                  '/': () => root.innerHTML = `首页`,
                  '/a': () => root.innerHTML = 'a页面'
              })
      
          </script>
      </body>
      </html>
      
  • hash模式与history模式总结

    • 1,hash模式:hash模式中是通过window.loaction.hash更改hash值保证url变化页面不变化,继而监听hash变化,执行当前hash对应更改SPA页面内容的回调函数来实现SPA中的前端路由。
    • 2,history模式:history模式中是通过H5提供的history.pushState/history.replaceState更改url保证url变化页面不变化,继而监听url变化,执行当前url对应更改SPA页面内容的回调函数来实现SPA中的前端路由。
  • 二者优缺点:

    • hash模式优点(history模式缺点):hash模式兼容性好,也不需要服务端处理非当前SPA页面的URL
    • hash模式缺点(history模式优点):hash模式中url中有#,比较奇怪,而且hash模式会导致一些锚点功能失效(因为锚点很可能没有对应任何SPA页面内容更新函数),且在hash模式中相同hash值不会推入到url历史栈内,而history模式可以,hash模式不利于seo,因为蜘蛛不认为hash是一个新url地址,所以不会收录,所以上线我们一般使用historyRouter。

4,属性的数据属性与访问器属性

  • 数据属性与访问器属性:对象属性上有一些属性用来描述当前对象属性的特征,这些描述特征的属性分为两种,一种是数据属性,一种是访问器属性,二者不会同时存在。

  • 数据属性:数据属性属性分为以下四种

    truefalse
    [[Configurable]]①可以使用delete删除当前对象属性,②可以修改当前对象属性的四个数据属性,③可以将当前对象属性修改成访问器属性①不可使用delete删除当前对象属性,②不可修改当前对象属性的Configurable,Enumerable,且Writable只能从true修改成false,但不能由false修改成true,③不可将当前对象属性修改成访问器属性
    [[Enumerable]]可以使用for in遍历获取当前对象属性不可以使用for in遍历获取当前对象属性
    [[Writable]]可以修改当前对象属性值不可以修改当前对象属性值
    [[Value]]value为当前对象属性值,可以是任意值,不像前面三个属性局限于true、false
  • 访问器属性:访问器属性分为以下四种

    truefalse
    [[Configurable]]①可以使用delete删除当前对象属性,②可以修改当前对象属性的四个访问器属性,③可以将当前对象属性修改成数据属性①不可使用delete删除当前对象属性,②不可修改当前对象属性四个访问器属性,③不可将当前对象属性修改成数据属性
    [[Enumerable]]可以使用for in遍历获取当前对象属性不可以使用for in遍历获取当前对象属性
    [[Get]]值为函数,默认该函数返回当前对象属性值
    [[Set]]值为函数,默认该函数将接收值设置为当前对象属性值
  • 数据属性与访问器属性关系:

    • 二者都是描述当前对象属性的一种方式,但只能使用二者中的一种,
    • 数据属性与访问器属性都有Configurable与Enumerable属性,Configurable略有区别,Enumerable没区别
    • 数据属性与访问器属性只有在Configurable在true的情况下可以相互转换
      • 数据属性转访问器属性:使用Object.defineProperty添加访问器属性中的get/set方法任何一个即可
      • 访问器属性转数据属性:使用Object.defineProperty添加数据属性中的writable/value属性任何一个即可
      • 注意:在设置特征属性的时候不能同时设置数据属性(writable/value)与访问器属性(get/set),会报错。
  • 获取对象特征属性(数据属性与访问器属性)的方法:

    • 1,Object.getOwnPropertyDescriptor(obj,'a'):即获取obj对象上的a属性的特征属性
    • 2,Object.getOwnPropertyDescriptors(obj):即获取obj对象上所有属性的特征属性
  • 设置特征属性(数据属性与访问器属性)的方法:

    • 非显示设置特征属性:即使用{a:1}或者const obj = {};obj.a = 1等方法设置对象属性,其特征属性默认为数据属性,三个布尔值特质属性为true,value则为设置的对象属性值,没设置则为undefined。 image.png
    • 显示设置特征属性:使用Object.defineProperty/Object.defineProperties分别用来设置一个或多个对象属性的特征属性。
      • Object.defineProperty:第一个参数为设置的对象,第二个参数为设置的对象属性,第三个参数为具体特征属性

        const obj1 = {}
        Object.defineProperty(obj1, 'a', {
            configurable: true, 
            enumerable: false,
            
            writable: true,
            value: 1,
            // get(){},
            // set(){},
        })
        
      • Object.defineProperties:第一个参数为设置的对象,第二个参数为当前对象的多个属性及其对应需要设置的具体特征属性

        const obj1 = {}
        Object.defineProperties(obj1, {
            'a': {
                value: 1
            },
            'b': {
                configurable: true,
                enumerable: false,
                writable: true,
                value: 2
            },
            // ...other property
        })
        
      • 注意:如果当前属性是初次使用Object.defineProperty/ies方法进行显示设置特征属性(即之前没有被显示或者非显示设置过特征属性),那么六个特征属性中如果存在没被设置的属性,那么这些属性会取默认值,如下:

        ConfigurableEnumerableWritableValueGetSet
        默认值falsefalsefalseundefinedundefinedundefined

        image.png

5,实现模块化commonjs中的require

  • 实现思想:require本质操作即:读取js文件内容将其包裹在函数中,注入准备好的module对象,执行函数,函数将内部导出的数据放到module中,这样我们就可以从module中获取js文件导出的数据。
  • 具体实现步骤:
    const { resolve, extname } = require('path')
    const fs = require('fs')
    const { runInThisContext } = require('vm')
    
    class Module {
        constructor() {
            this.exports = {}
        }
    }
    Module.cache = []
    Module.extnames = ['.js', '.json']
    
    function require_(path) {
        // 1,处理成绝对路径
        path = resolve(path)
        // 2,仅处理JS与JSON文件
        if (!Module.extnames.includes(extname(path))) return
        // 3,判断有无该文件,无则推出
        try {
            fs.accessSync(path)
        } catch (error) {
            return
        }
        // 4,查找缓存是否有已缓存数据,有的话直接返回缓存数据
        if (Module.cache[path]) return Module.cache[path].exports
        // 5,创建module对象 { exports:{} }
        const module = new Module()
        // ~~~6,提前将module放在缓存中,应对CJS中循环引用
        Module.cache[path] = module
        // ~~~提前将module放在缓存中,应对CJS中循环引用
        // 7,处理JSON文件
        if (extname(path) === '.json') {
            const content = fs.readFileSync(path, 'utf8')
            module.exports = content
            return module.exports
        } 
        // 9,处理JS文件
        else {
            const script = `(function (require,module,exports){${fs.readFileSync(path, 'utf8')}})`
            const fn = runInThisContext(script)
            fn.call(this, require_, module, module.exports)
            return module.exports
        }
    }
    

6,offset- | client- | scroll- | event- | getBoundingClientRect()- | window- 中坐标属性详解

  • offset家族:offsetParent | offsetWidth | offsetHeight | offsetTop | offsetLeft

    • offsetParent

      • offsetParent作用: offsetTop | offsetLeft 都是当前元素相对于其offsetParent元素计算得出

      • offsetParent确定:HTMLElement.offsetParent是一个只读属性,如果父级存在定位元素,则当前元素的offsetParent指向该定位父级元素,如果不存在定位父级元素,则该元素指向最近的table/td/th/body父级元素(table/td/th比较少见罢了)。

      • offsetParent注意:

        • 1,webkit中,如果当前元素或者该元素父元素 display:none或者当前元素position:fixed,则当前元素的offsetParent为null。
        • 2,ie9中当且仅当当前元素position:fixed,则当前元素的offsetParent为null。
    • offsetWidth:offsetWidth为一个只读属性,返回布局元素的宽度,其计算方式为:offsetWidth = 左border外侧到右border外侧的距离 + 竖直方向滚动条宽度(如果存在)

    • offsetHeight:同offsetWidth,其计算方式为:offsetHeight = 上border外侧到下border外侧的距离 + 水平方向滚动条宽度(如果存在)

    • offsetLeft:offsetLeft为一个只读属性,返回当前元素左border外侧到offsetParent左border内侧距离。

    • offsetTop:offsetHeight为一个只读属性,返回当前元素上border外侧到offsetParent上border内侧距离。

    image.png

  • client家族:clientWidth | clientHeight | clientLeft | clientTop

    • clientWidth:内联元素clientWidth为0,非内联元素clientWidth为前元素左border内侧到右border内侧的宽度

    • clientHeight:内联元素clientHeight为0,非内联元素clientHeight为当前元素上border内侧到下border内侧的宽度

    • clientTop:元素的border-top-width

    • clientLeft:元素的border-left-width

    image.png

  • scroll家族:scrollWidth | scrollHeight | scrollLeft | scrollTop

    • scrollWidth:在当前元素内容不发生溢出情况下,与clientWidth一致,如果发生内容溢出,则为当前clientWidth+溢出部分宽度,如下图

      image.png

    • scrollHeight:同scrollWidth,在当前元素内容不发生溢出情况下,与clientHeight一致,如果发生内容溢出,则为当前clientHeight+溢出部分高度。

    • scrollLeft:横向滚动中,滚动内容宽度是scrollWidth,scrollLeft可以看做scrollWidth区域向左滚动导致隐藏部分的宽度,如下图

      image.png

    • scrollTop:同scrollLeft,竖直滚动中,滚动内容高度是scrollHeight,scrollTop可以看做scrollHeight区域向上滚动导致隐藏部分的宽度。

  • event事件上的坐标

    • e.offsetX,e.offsetY:事件触发所在元素左上角border内侧为原点,事件触发位置的坐标
    • e.clientX,e.clientY:body左上角border外侧为原点,事件触发位置的坐标
    • e.screenX、e.screenY:电子屏幕左上角为原点,事件触发位置的坐标
    • e.pageX、e.pageY:body不出现滚动条时,body左上角border外侧为原点(与clientXY相同),事件触发位置的坐标,不过如果发生body溢出,那么原点将位于完整body的左上角border外侧,看图,解释的可能不好。

    image.png

  • element.getBoundingClientRect()家族

    • element.getBoundingClientRect().width:当前元素左border外侧到右border外侧距离
    • element.getBoundingClientRect().height:当前元素上border外侧到下border外侧距离
    • element.getBoundingClientRect().top:当前元素上border外侧视口最顶部距离
    • element.getBoundingClientRect().left:当前元素左border外侧视口最左侧距离
    • element.getBoundingClientRect().right:当前元素右border外侧视口最左侧距离
    • element.getBoundingClientRect().bottom:当前元素下border外侧视口最顶部距离

    image.png

  • window家族

    • window.innerWidth:浏览器视口宽度

    • window.innerHeight:浏览器视口高度

    • window.outerWidth:浏览器宽度

    • window.outerHeight:浏览器高度

    • window.screen.width:电子屏幕宽度

    • window.screen.height:电子屏幕高度 image.png

7,实现一个基础版本axios

  • 该版本axios包含官方axios整体实现流程,实现了拦截器,取消请求,浏览器发送get,post请求等功能,该html包含连个js脚本,第一个脚本负责实现axios(全代码注释),第二个脚本负责测试axios,服务器的话需要自己实现,推荐json-server(github),应该叫这个名字,30s搭建一个服务器(readme有说如何使用json-server),直接使用。

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
    </head>
    
    <body>
        <button class="btn" id='addPost'>post</button>
        <!-- 此脚本实现基础axios,包括拦截器,取消请求,axios(),axios.request(),axios.get(),axios.post()四种请求方式 -->
        <script>
            // Interceptor: 拦截器构造函数,this.handles保存拦截器成功失败回调
            function Interceptor() {
                this.handles = []
            }
            // Interceptor.prototype.use:将拦截器成功失败回调推入this.handles
            Interceptor.prototype.use = function (successCallback, errorCallback) {
                this.handles.push({ successCallback, errorCallback })
            }
    
            // Axios:axios构造函数
            // this.defaults保存默认axios网络请求的配置(比如默认请求方式get)
            // this.interceptors保存request与response拦截器对象(添加拦截器的操作: axios.interceptors.request.use(success,errCallback))
            function Axios(defaultConfig) {
                this.defaults = defaultConfig
                this.interceptors = {
                    request: new Interceptor,
                    response: new Interceptor
                }
            }
            // Axios.prototype.request:axios.request请求方式实现
            // 其实axios不同的请求发起方式(axios(),axios.request(),axios.get()...) 实际上最终都是通过调用Axios.prototype.request实现的
            Axios.prototype.request = function (config) {
                // 将使用者传入的网络请求配置对象与默认配置合并
                config = Object.assign(this.defaults, config)
                // 创建一个promise对象,value为合并后的配置config
                let promise = Promise.resolve(config)
                // 创建一个promise.then的函数执行链 即未来 promise.then(dispatchRequest, undefined)
                let chain = [dispatchRequest, undefined]
                // 将请求响应拦截器加入到chain中,位于chain中不同位置对应不同执行时机
                this.interceptors.request.handles.forEach(({ successCallback, errorCallback }) => {
                    chain.unshift(successCallback, errorCallback)
                })
                this.interceptors.response.handles.forEach(({ successCallback, errorCallback }) => {
                    chain.push(successCallback, errorCallback)
                })
                // promise.then(chain.shift(), chain.shift()) 形式依次执行请求拦截器,请求发送,响应拦截器
                while (chain.length) {
                    promise = promise.then(chain.shift(), chain.shift())
                }
                // 返回最终经由请求拦截器,请求发送,响应拦截器 所获得最终服务器返回数据
                return promise
            }
            // Axios.prototype.get:axios.get请求方式实现
            Axios.prototype.get = function (config) {
                return this.request(Object.assign(config, { method: 'get' }))
            }
            // Axios.prototype.post:axios.post请求方式实现
            Axios.prototype.post = function (config) {
                return this.request(Object.assign(config, { method: 'post' }))
            }
            // Axios.prototype.CancelToken:axios取消请求实现
            // CancelToken基本使用: 作为一个构造函数,传入一个函数,该函数接受一个为函数的参数,执行该函数即可取消当前网络请求,即 new CancelToken(c=>window.cancel = c),window.cancel()即可取消当前网络请求
            // axios取消请求实现原理:new CancelToken将创建一个promise,promise状态改变通过传入的函数交给外界,外界执行该函数(window.cancel())即可改变构造函数内部promise的状态
            // 而promise状态最终会关联到网络请求取消与否上,即promise.then(()=>{xhr.abort()}),所以一旦外界改变promise状态,promise.then执行,网络请求取消,而只要外界不改变promise状态,promise.then就不会执行,网络请求就不会取消
            // 这样就实现了由使用者控制何时进行网络请求的取消
            Axios.prototype.CancelToken = function (excutor) {
                let promiseResolve
                this.promise = new Promise(resolve => promiseResolve = resolve)
                excutor(() => promiseResolve())
            }
    
            // dispatchRequest:axios可以再浏览器环境与node环境中发送请求,因为浏览器使用的是xhr,node使用http,所以dispatchRequest在这里做个区分使用哪种方式发送网络请求
            // 这里只实现浏览器网络请求发送,node请求暂未实现
            function dispatchRequest(config) {
                // 直接调用xhrAdapter发送网络请求
                return xhrAdapter(config).then(
                    response => response,
                    err => { throw (err) }
                )
            }
    
            // xhrAdapter:原生浏览器网络请求封装成Promise形式
            function xhrAdapter(config) {
                return new Promise((resolve, reject) => {
                    const xhr = window.XMLHttpRequest ? new XMLHttpRequest : new ActiveXObject
                    //  xhr.onreadystatechange:监听网络请求状态,合适的实际resolve/reject当前Promise
                    xhr.onreadystatechange = function () {
                        if (xhr.readyState === 4) {
                            if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
                                // 将响应成功结果,处理成axios返回的数据格式
                                resolve({
                                    config: config,
                                    data: JSON.parse(xhr.response),
                                    status: xhr.status,
                                    statusText: xhr.statusText,
                                    request: xhr,
                                    headers: xhr.getAllResponseHeaders()
                                })
                            } else {
                                reject(new Error(`请求失败 ${xhr.status}`))
                            }
                        }
                    }
                    // 准备一个xhr请求,等待发送
                    xhr.open(config.method, config.url)
                    // 在请求发送前处理设置好请求头
                    Object.entries(config.headers).forEach(([key, value]) => {
                        xhr.setRequestHeader(key, value)
                    })
                    // 请求发送
                    xhr.send(JSON.stringify(config.data))
                    // 实现axios取消请求
                    config.cancelToken && config.cancelToken.promise.then(() => xhr.abort())
                })
            }
    
            // createAxios:创建axios实例的函数,因为axios需要实现axios(),axios.request(),axios.get()等方式发起请求
            // 仅仅new Axios()返回的axios实例并不支持axios()方式发起请求,所以该函数目的即创建一个可以使用多种请求方式的axios实例
            function createAxios(defaultConfig) {
                // 创建Axios实例
                let axiosInstance = new Axios(defaultConfig)
                // 创建axios,此时axios()请求即可实现,但是axios.request()/axios.get()等方式还未实现(注意维持this指向)
                let axios = Axios.prototype.request.bind(axiosInstance)
                // 实现axios.request()/axios.get()请求方式,原理很简单,将request、get直接挂到axios函数对象上即可(注意维持this指向)
                Object.keys(Axios.prototype).forEach(key => axios[key] = Axios.prototype[key].bind(axiosInstance))
                // 实现axios.defaults获取默认配置对象,axios.interceptors获取拦截器对象,同上,直接就将Axios实例axiosInstance中这些数据拷贝到axios函数对象上即可
                Object.keys(axiosInstance).forEach(key => axios[key] = axiosInstance[key])
                // 返回缝合后的axios
                return axios
            }
    
            // defaultConfig: 默认网络请求配置,这里仅简单配置了默认请求方式get,以及两个请求头字段,源码中该默认请求配置更多
            let defaultConfig = {
                "method": "get",
                "headers": {
                    "Accept": "application/json,text/plain,*/*",
                    "Content-Type": "application/json;charset=utf-8"
                }
            }
            // 将axios挂载到全局对象上,这里我们只实现浏览器的axios,所以挂到window上,这样引入该文件,即可直接使用axios
            window.axios = createAxios(defaultConfig)
        </script>
        <!-- 此脚本可测试实现的axios,如果期望快速搭建一个测试服务器(in 30s),推荐json-server,readme写的很清楚这家伙如何使用-->
        <script>
            // 1,测试请求与响应拦截器功能
            axios.interceptors.request.use(
                config => { console.log('请求拦截器1'); return config },
                err => Promise.reject(err)
            )
            axios.interceptors.request.use(
                config => { console.log('请求拦截器2'); return config },
                err => Promise.reject(err)
            )
    
            axios.interceptors.response.use(
                res => { console.log('响应拦截器1'); return res },
                err => Promise.reject(err)
            )
            axios.interceptors.response.use(
                res => { console.log('响应拦截器2'); return res },
                err => Promise.reject(err)
            )
            // 2,测试取消请求功能(连续点击button即可取消上一次网络请求,当然网络请求请确保有点时延)
            let cancel = null
            // 3,测试axios进行网络请求(点击按钮发送网络请求)
            addPost.addEventListener('click', function () {
                cancel && cancel()
                // 4,当然这里也可以换成axios.request、axios.post形式测试
                axios({
                    method: 'post',
                    url: 'http://localhost:3000/posts',
                    data: { title: "json-server-01", author: "axios" },
                    cancelToken: new axios.CancelToken(c => cancel = c)
                }).then(res => console.log(res))
            })
    
        </script>
    </body>
    
    </html>
    

8,关于parseInt

  • parseInt(string,radix): parseInt接受两个参数string与radix。

    • string参数:参数string是必须的,且如果该参数不是字符串类型,将会将其转成字符串类型(调用其toString方法)作为其参数

    • radix参数:参数radix不是必须的,可以省略,如果不省略,合法的radix参数应该是一个2-36的数字,且如果传入参数不是数字类型,则会将其转换成数字类型(调用其valueOf方法)作为其参数

    • 返回值:将string参数当前为radix进制,再将该进制转化成十进制数返回,且结果只有两种情况分别是十进制数或者NaN。

  • 关于radix参数:

    • 当radix是这2种情况:undefined(即parseInt(string,undefined)或parseInt(string)),0(即parseInt(string,0))。radix将会根据string参数确定其进制,有以下几种情况:

      • 如果string参数以0x0X 开头,radix将被确定为16进制,比如 parseInt('0xA7') 相当于parseInt('A7',16) ,其解析结果计算方式即 10*16^1+7*16^0 ,所以结果是167。

      • 如果string参数以 0 开头,radix将被确定为10进制或者8进制,其中ES5表示应该使用10进制,但并非所有浏览器均支持, 所以我们在使用parseInt时应该指明radix的值,防止出现意料之外的结果。 现在以radix在此种情况下确定为10进制举例,比如 parseInt('010') 相当于parseInt('10',10) ,其解析结果计算方式即 1*10^1+0*10^0 ,所以结果是10。

      • 如果string参数是其他值开头,那么radix都默认为10进制处理。

    • 除了radix为0与undefined之外,如果指定radix参数,且radix不再2-36范围之内,那么parseInt返回值为NaN

  • 关于string参数

    • 对于string参数的解析,parseInt只会忽略string参数前面的空白符,但不会忽略字符串中以及字符串后的空白符,即 parseInt(' 12 3',10)parseInt('12 3',10) 是一样的。

    • parseInt将对string参数按位解析,遇到不符合radix进制内的位值或者其他非法字符,将从该位停止解析,返回前面合法位解析出来的值。可能不太好理解,没关系,看下面两个例子:

      • 不符合radix进制情况:以 parseInt('1024',2) 为例,其中字符串1024中第三位值2就不符合2进制的数据,所以从2以及往后的数据都不会进行解析,所以parseInt('1024',2) 相当于 parseInt('10',2) 的解析,结果即 1*2^1+0*2^0 ,即2

      • 其他非法字符情况:以 parseInt('10 24',2) 为例,其中字符串1024中第三位值为空格即没办法解析,所以从第三位空格以及往后的数据都不会进行解析,所以parseInt('10 24',2) 相当于 parseInt('10',2) 的解析,结果即 1*2^1+0*2^0 ,即2

    • parseInt能正确识别 + 与 - 即正负号,所以 parseInt('-10',2) 解析结果就是 -1*(1*2^1+0*2^0)-2

    • 如果string参数中从一开始就没有解析到合法字符,返回NaN,像这样 parseInt(' A1',10) ,parseInt去掉string参数前面空格变成这样parseInt('A1',10) ,A就是非法字符,所以从第一位A到最后都不回被解析,因此返回NaN

  • 了解parseInt使用后我们做个小练习, ['1','2','3'].map(parseInt) 输出为什么是 [1,NaN,NaN] 。下面分析一下这行代码执行过程:

    • 首先map第一个参数应该是函数,这里我们传入parseInt作为map第一个参数,同时map会向该函数注入三个参数,分别为 当前元素值,当前元素下标,当前元素所在数组,所以['1','2','3'].map(parseInt) 的处理过程相当于['1','2','3'].map((v,i,arr)=>parseInt(v,i)) ,因为parseInt最多只接受两个参数,所以对第三个参数arr忽略。

    • 所以我们只需要计算出parseInt('1','0')parseInt('2','1')parseInt('3','2')结果即可得到答案。

    • 首先 parseInt('1','0') ,因为radix为0,所以相当于parseInt('1',10) 即将1转换为10进制,因此输出1

    • 其次 parseInt('2','1'),即将2转成1进制,radix合法值位于2-36,不存在1进制这种,因此返回NaN。

    • 最后 parseInt('3','2'),即将3转成2进制,而在二进制中,位最大只能是1,满1进位,3在2进制中属于不合法的,所以返回NaN

    • 因此['1','2','3'].map(parseInt) 最后输出 [1,NaN,NaN] !

9,关于深拷贝

// getType:精确获取数据类型
const getType = val => Object.prototype.toString.call(val).slice(8).replace(/\]/g, '').toLowerCase()
// wm:防止引用类型递归时出现循环引用
const wm = new WeakMap()
// 深拷贝
function deepCopy(val) {
    // 处理基本类型:直接返回原值(!(val instanceof Object)用来排除包装类型number string boolean)
    if (['number', 'string', 'boolean', 'symbol', 'null', 'undefined'].includes(getType(val)) && !(val instanceof Object)) return val
    // 处理引用类型 array/object/arguments:递归处理
    if (['array', 'object', 'arguments'].includes(getType(val))) {
        console.log('1');
        if (wm.has(val)) return val
        const copyVal = new val.constructor()
        wm.set(val, copyVal)
        for (const key in val) val.hasOwnProperty(key) && (copyVal[key] = deepCopy(val[key]))
        return copyVal
    }
    // 处理引用类型set:(递归处理)
    if (['set'].includes(getType(val))) {
        if (wm.has(val)) return val
        const copyVal = new val.constructor()
        wm.set(val, copyVal)
        val.forEach(v => copyVal.add(deepCopy(v)))
        return copyVal
    }
    // 处理引用类型map:(递归处理)
    if (['map'].includes(getType(val))) {
        if (wm.has(val)) return val
        const copyVal = new val.constructor()
        wm.set(val, copyVal)
        val.forEach((v, k) => copyVal.set(k, deepCopy(v)))
        return copyVal
    }
    // 处理正则
    if (['regexp'].includes(getType(val))) {
        const source = val.source                           // 使用val.source取出正则主体,
        const modify = val.toString().match(/\w*$/)[0]      // 用val.toString().match(/\w*$/)[0]取出正则修饰符,
        const copyVal = new val.constructor(source, modify) // 重新创建正则
        copyVal.lastIndex = val.lastIndex                   // 重新创建正则挂上原正则的lastIndex(拷贝原正则的lastIndex,lastIndex用于全局匹配/g时标明每一次匹配所在当前位置)
        return copyVal
    }
    // 处理函数
    if (['function'].includes(getType(val))) {
        // 如果是箭头函数直接eval转原函数字符串
        if (!val.prototype) return eval(val.toString())
        // 如果是普通函数,正则获取原函数参数信息与函数体信息,使用new Function重新实例新函数
        const param = val.toString().match(/(?<=\()(.|\n)*(?=\)\s*\{)/)[0]
        const body = val.toString().match(/(?<=\)\s*\{)(.|\n)*(?=\})/)[0]
        return param ? new Function(...param.split(','), body) : new val.constructor(body)
    }
    // 处理其他引用类型 Number String Boolean Date Error ... : 使用其构造函数传入源值返回拷贝值
    return new val.constructor(val)
}

10,关于交叉观察器(IntersectionObserver)

  • IntersectionObserver: 中译交叉观察器,提供了一种异步观察目标元素与其祖先元素/顶级文档视口交叉状态的方法。这里着重讲如何使用交叉观察器判断一个元素是否进入视口(实现图片懒加载,无限滚动等效果)

    • 创建一个交叉观察器实例,其中IntersectionObserver是浏览器原生构造函数,参数callback为元素可见性变化时执行的回调函数,参数option是配置选项(可选)

      const io = new IntersectionObserver(callback,option)
      
    • 使用交叉观察器对dom元素 element1与element2进行观察,取消观察,关闭交叉观察器

      // 开始观察element1与element2
      io.observe(element1)
      io.observe(element2)
      
      // 停止观察element1与element2
      io.unobserve(element1)
      io.unobserve(element2)
      
      // 关闭交叉观察器
      io.disconnect()
      
    • 交叉观察器构造函数第二个可选配置对象参数options中两个重要参数threshold(中译:临界值)与 root

      • 参数threshold决定什么时候触发回调函数,它是个数组,每个成员都是个临界值,比如这里threshold:[0,0.5,1]就是目标元素0%,50%,100%可见时触发回调函数,默认不设置threshold为[0],即目标元素0%可见时触发回调函数

        const io = new IntersectionObserver(callback,{threshold:[0,0.5,1]})
        
      • 参数root指定目标元素相对的父元素,很多时候元素不仅相对于视口滚动,也可能需要相对于其某个祖先容器元素滚动,所以我们可以指定目标元素所在的容器元素是谁,比如这里,指定其容器元素为id:container的元素。

        const io = new IntersectionObserver(callback,{root:document.getElementById('container')})
        
    • 交叉观察器构造函数第一个参数 callback,当目标元素可见性发生变化时就会调用该回调函数。

      const io = new IntersectionObserver(entries=>{ })
      
      • 回调函数参数entries是个数组,数组每个元素对应注册的元素(io.observe(element1))可见性相关信息,其中两个重要的数据为 target 与 intersectionRadio(能够帮助我们判断元素是否进入视口)

        const io = new IntersectionObserver(entries=>console.log(entries[0]))
        // entries[0]输出:
        {
          target:'被观察的目标元素'
          intersectionRadio:'目标元素的可见比例,我们可以通过该值判断该元素是否可见(比如图片懒加载时判断图片元素是否可见),完全可见时为1,完全不可见时为0,可见一半为0.5等'
          time:'可见性发生变化的时间'
          //  ... 其他信息
        }
        
  • 使用交叉观察器实现图片懒加载与传统JS实现图片懒加载:

    <!DOCTYPE html>
    <html lang="en">
    
    <body>
        <div>
            <div style="height: 2000px;"></div>
            <img id='img1' data-src="https://img1.baidu.com/it/u=3246628741,3439955235&fm=26&fmt=auto" />
            <img id='img2' data-src="https://img2.baidu.com/it/u=3537291678,570975452&fm=26&fmt=auto" />
        </div>
        <script>
            // intersectionObserver 交叉观察器实现懒加载
             function lazyLoad(...list) {
                const io = new IntersectionObserver(entries => entries.forEach(item => {
                    if (item.isIntersecting) {
                        item.target.src = item.target.dataset.src
                        io.unobserve(item.target)
                    }
                }))
                list.forEach(v => io.observe(v))
            }
            lazyLoad(img1, img2)
            // getBoundingClientRect 监听scroll实现懒加载
            // 监听滚动(scroll)事件,滚动事件回调函数判断元素是否进入视口,如果进入视口,则加载图片
            function lazyLoad_old(...list) {
                const viewHeight = window.innerHeight
                const callback = e => {
                    // 1,如果不存在懒加载元素,则取消滚动监听
                    if (!list.length) return window.removeEventListener('scroll', callback)
                    // 2,读写分离list:读-list;写-nextList
                    const nextList = list.slice()
                    list.forEach((el, i) => el.getBoundingClientRect().top - viewHeight <= 0 && (el.src = el.dataset.src, nextList.splice(i, 1)))
                    // 3,list更新为nextList
                    list = nextList
                }
                window.addEventListener('scroll', callback)
            }
            lazyLoad_old(img1, img2)
        </script>
    </body>
    
    </html>
    
  • 使用交叉观察器实现无限滚动原理:一般在滚动容器尾部添加一个尾部元素,我们对这个尾部元素进行观察,一旦该元素出现,则加载新的资源

11,关于虚拟列表

虚拟列表:简而言之,对于大量列表数据,仅渲染滚动区域内可视区域 所对应 数据源 的部分数据

举个例子:

  • 比如我要渲染1000条数据,每条数据高度60px,如果我想把全部元素渲染,那么滚动区域至少需要6000px高但是呢,如果我渲染1000条数据,那么整个dom树就会出现巨量dom元素,如下图,B,C,D区域将全部都是dom元素,一旦回流重绘就会非常卡顿。

image.png

  • 这种场景的优化方案就是采取虚拟列表去渲染,即我们要做的就是在滚动的时候只渲染可视区域对应的dom元素,即下图的B,非可视区域内元素不去渲染(即C,D区域不渲染元素),这样的话可以减少大量非必要DOM渲染(C,D区域用户也不需要看见,所以它的dom渲染是非必要的),去提高性能!

虚拟列表实现原理:根据视口高度(上图中的B区域高度),父元素的滚动距离(scrollTop)及每一个列表元素item的平均高度,计算出当前scrollTop到scrollTop+视口高度这段可视区域 对应数据源列表所需要展示的 部分数据,再将这部分数据展示在B区域内,对于隐藏滚动区域C,D使用paddingTop&&paddingBottom填充,保持整个滚动区域高度不变,同时监听滚动,滚动触发时动态改变可是区域内数据及paddingTop&&paddingBottom等数据:

  • 1,监听容器元素滚动

  • 2,获取容器元素滚动距离scrollTop

  • 3,获取当前滚动距离(scrollTop)下可视区域首个需要渲染元素索引(in 数据源)startIndex,计算方式:startIndex = 滚动距离(scrollTop)/ 列表元素高度(itemHeight)

  • 4,获取当前滚动距离(scrollTop)下可视区域最后一个需要渲染元素索引(in 数据源)endIndex,计算方式:endIndex = startIndex + 可视区域高度(viewHeight)/ 列表元素高度(itemHeight)

  • 5,根据startIndex和endIndex获取数据源对应的 可视区域需要展示的数据viewData,计算方式:viewData =dataSource(数据源).slice(startIndex,endIndex+1)

  • 6,计算C区域的高度,既paddingTop,计算方式:paddingTop = startIndex*itemHeight(列表元素高度)

  • 7,计算D区域的高度,既paddingBottom,计算方式:paddingBottom = contentHeight(整个滚动区域高度,既A区域)-paddingTop-可视区域高度(viewHeight)

  • 8,清除可是区域内上一次展示的数据,使用新数据viewData重新填充,且设置新的paddingTop&paddingBottom

虚拟列表代码简单实现:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        html,
        body {
            width: 100%;
            height: 100%;
            margin: 0;
        }

        #container {
            width: 100%;
            height: 100%;
        }

        #content {
            background-color: pink;
        }
    </style>
</head>

<body>
    <div id="container">
        <div id="content"></div>
    </div>
    <script>

        function createVlist(container, content, data_Source, item_Height) {
            const dataSource = data_Source  // 数据源
            const itemHeight = item_Height  // 每一行元素高度
            const viewHeight = container.clientHeight // 视口高度(虚拟列表所展示部分高度)
            const contentHeight = itemHeight * dataSource.length // 滚动区域高度
            const itemCount = Math.ceil(viewHeight / itemHeight) // 视口元素数量
            // 设置容器元素overflow: auto (生成滚动区域,不被截取)
            container.setAttribute('style', `overflow:auto`)
            // 设置滚动区域高度
            content.setAttribute('style', `height:${contentHeight}px`)
            const scrollCallback = e => {
                const scrollTop = e.target.scrollTop                 // 容器元素的滚动距离
                const startIndex = Math.ceil(scrollTop / itemHeight) // 视口第一个元素所在数据源中的索引
                const endIndex = startIndex + itemCount              // 视口最后一个元素所在数据源中的索引
                // 根据startIndex endIndex找出数据源中需要展示在虚拟列表中的部分数据
                const itemList = dataSource.slice(startIndex, endIndex + 1)

                // 滚动区域高度contentHeight =  滚动区域paddingTop + 视口高度 + 滚动区域paddingBottom
                const paddingTop = startIndex * itemHeight
                const paddingBottom = contentHeight - paddingTop - itemCount * itemHeight

                // 动态调整滚动区域的paddingTop  paddingBottom 保证列表部分始终展示在视口
                content.setAttribute('style', `padding-top:${paddingTop}px;padding-bottom:${paddingBottom}px`)
                // 展示下一批次列表前删除上一批次列表数据
                content.innerHTML = ''
                // 动态调整每次滚动后对应的展示数据
                for (const val of itemList) {
                    const item = document.createElement('div')
                    item.innerHTML = val
                    item.setAttribute('style', `background-color:${val % 2 === 0 ? 'red' : 'blue'};width:100%;height:${item_Height}px`)
                    content.appendChild(item)
                }
            }
            // 添加容器元素的滚动监听
            container.addEventListener('scroll', scrollCallback)
            // 初始首屏数据
            scrollCallback({ target: container })
        }
        // 创建虚拟列表
        createVlist(container, content, Array.from({ length: 100 }, (v, i) => i), 60)

    </script>
</body>

</html>

效果:

Sep-13-2021 18-32-12.gif

感谢参考