阅读视图

发现新文章,点击刷新页面。

GeoJSON 介绍:Web 地图数据的通用语言

GeoJSON 介绍:Web 地图数据的通用语言

引言

GeoJSON 是一套基于 JSON 格式的地理空间数据编码标准,具有轻量、易读、易于在 Web 应用中解析和传输等优势,它是 Web 地图库(如 Leaflet, Mapbox, OpenLayers)事实上的标准数据格式,我最近在看 OpenLayers,在加载外部数据的时候都是用 GeoJSON,于是便了解了一下,这里是最新规范的英文文档、英语好的可以直接跳转这里

GeoJSON 基本构成

GeoJSON 本质上就是一个标准的 JSON 对象,所有 GeoJSON 对象必须有一个 "type" 成员,"type"表示当前 JSON 描述的类型,这里分为基本几何类型和特征类型。

基本几何类型快速理解就是描述地图上形状的类型,“type” 取值包括 点 (Point)、线(LineString)、区域(Polygon)以及他们的多重类型 MultiPoint, MultiLineString, MultiPolygon,其“coordinates” 属性用来标注地理坐标位置(经纬度基于 WGS84 坐标系)

特征类型即带有属性(properties)的类型,“type” 取值包括 Feature 和 FeatureCollection

基本几何类型

Point(点)

表示地图上的一个点,结构如下

{
  "type": "Point",
  "coordinates": [106.56, 29.57]
}

LineString (线)

表示地图上的一条线,可以理解为有多个点连接组成,“coordinates” 为一个二维数组

{
  "type": "LineString",
  "coordinates": [
    [106.51398305678325, 29.523171668355733],
    [106.51453664249686, 29.523092142346467],
    [106.51566579820047, 29.522995404990354]
  ]
}

Polygon (多边形)

表示地图上的一个多边形,“coordinates” 由多个环组成、环即由多个点组成的闭合的路径、最后一个点表示闭合点,注意这里可能包含多个环形元素,第一个环表示外部环、其余表示内部环,比如空心圆就由一个内部环和一个外部环组成、外部环通常由逆时针顺序定义,内部的洞应以顺时针方向定义。

一个包含环的多边形示例如下

{
  "type": "Polygon",
  "coordinates": [
    [
      [106.50, 29.60],
      [106.50, 29.50],
      [106.60, 29.50],
      [106.60, 29.60],
      [106.50, 29.60] 
    ],
    [
      [106.53, 29.57],
      [106.57, 29.57],
      [106.57, 29.53],
      [106.53, 29.53],
      [106.53, 29.57]
    ]
  ]
}

把第二段JSON删掉就是没有环的矩形

{
  "type": "Polygon",
  "coordinates": [
    [
      [106.50, 29.60],
      [106.50, 29.50],
      [106.60, 29.50],
      [106.60, 29.60],
      [106.50, 29.60] 
    ]
  ]
}

MultiPoint(多点)

表示一组不相连的点、Point的复数形式、多个点组成的二维数组

{
  "type": "MultiPoint",
  "coordinates": [
    [106.50, 29.60],
    [106.50, 29.50],
    [106.60, 29.50],
    [106.60, 29.60]
  ]
}

MultiLineString (多线串)

表示一组不相连的线串,LineString的复数形式,多条线组成的三层数组

{
  "type": "MultiLineString",
  "coordinates": [
    [
      [106.51398305678325, 29.523171668355733],
      [106.51453664249686, 29.523092142346467],
      [106.51566579820047, 29.522995404990354]
    ],
    [
      [106.51398305678325, 29.533171668355733],
      [106.51453664249686, 29.533092142346467],
      [106.51566579820047, 29.532995404990354]
    ]
  ]
}

MultiPolygon (多多边形):

表示一组不相连的多边形、坐标是四层数组,每组坐标代表一个独立的 Polygon(每个 Polygon 内部仍可包含洞)。

{
  "type": "MultiPolygon",
  "coordinates": [
    [
      [
        [106.50, 29.55],
        [106.50, 29.50],
        [106.55, 29.50],
        [106.55, 29.55],
        [106.50, 29.55]
      ]
    ],
    [
      [
        [106.65, 29.65],
        [106.65, 29.60],
        [106.70, 29.60],
        [106.70, 29.65],
        [106.65, 29.65]
      ]
    ]
  ]
}

GeometryCollection (几何集合)

用于将不同类型(Point, LineString, Polygon, Multi*)的几何图形封装到一个对象中, 包含一个 "geometries" 成员,其值是一个数组,数组中的每个元素都是一个完整的 GeoJSON 几何对象

一个包含 Point, LineString, Polygon 的 对象

{
  "type": "GeometryCollection",
  "geometries": [
    {
      "type": "Point",
      "coordinates": [106.52, 29.53]
    },
    {
      "type": "LineString",
      "coordinates": [
        [106.50, 29.50],
        [106.53, 29.50],
        [106.53, 29.55]
      ]
    },
    {
      "type": "Polygon",
      "coordinates": [
        [
          [106.55, 29.55],
          [106.55, 29.50],
          [106.60, 29.50],
          [106.60, 29.55],
          [106.55, 29.55]
        ]
      ]
    }
  ]
}

特征类型

这个可以说是GeoJSON 的灵魂,它将几何形状与属性数据关联起来,使得图形有了意义,包括两个类型:“Feature” 和 “FeatureCollection”

Feature

基本结构如下:

  • "type": "Feature"
  • "geometry":包含一个几何对象(Point, Polygon, etc.)。
  • "properties":包含任何非地理属性数据(例如:名称、人口、年份、颜色等)。

下面为一个LineString、属性描述其为一条高速公路

{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [
      [106.50, 29.50],
      [106.55, 29.52],
      [106.60, 29.54],
      [106.65, 29.56]
    ]
  },
  "properties": {
    "id": "G5001",
    "name": "重庆绕城高速(部分)",
    "speed": 100,
    "length": 15.5
  }
}

FeatureCollection

FeatureCollection 表示Feature的集合,几乎网上下载的GeoJSON文件都是 FeatureCollection 结构的 基本结构:

  • "type": "FeatureCollection"
  • "features":一个包含零个或多个 Feature 对象的数组。

一个表示线路和服务区的 GeoJSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [106.40, 29.50],
          [106.45, 29.52],
          [106.50, 29.54],
          [106.55, 29.56],
          [106.60, 29.58]
        ]
      },
      "properties": {
        "id": "H-1234",
        "name": "城市快速通道A段",
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [106.45, 29.52]
      },
      "properties": {
        "id": "SA-001",
        "name": "龙溪服务区",
      }
    }
  ]
}

OpenLayers 中使用

在OpenLayers中写了两个Demo巩固、一个用于绘制线路后导出GeoJSON文件,另一个加载导出的文件根据渲染线路和服务区

绘制线路以及站点

涉及主要功能点:

  • 绘制线路,并为香炉加上线路表示以及名字
  • 绘制站点,并为站点加上站点标识以及名字
import { useEffect, useRef, useState } from 'react'
import * as layer from 'ol/layer'
import * as source from 'ol/source'
import { Map } from 'ol'
import View from 'ol/View.js'
import OSM from 'ol/source/OSM'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Fill from 'ol/style/Fill'
import * as format from 'ol/format'
import CircleStyle from 'ol/style/Circle'

import * as interaction from 'ol/interaction'
import type { DrawEvent } from 'ol/interaction/Draw'
const projection = 'EPSG:4326'

let i = 0

const createDrawStyle = () => {
  return new Style({
    image: new CircleStyle({
      radius: 4,
      fill: new Fill({
        color: '#000000'
      })
    }),
    stroke: new Stroke({
      width: 2,
      color: 'red'
    })
  })
}
const DrawLine = () => {
  const mapRef = useRef<Map>(null)
  const drawRef = useRef<interaction.Draw>(null)
  const drawSource = useRef<source.Vector>(null)
  const [drawType, setDrawType] = useState<string>('Point')

  useEffect(() => {
    const vectorSource = new source.Vector()
    drawSource.current = vectorSource
    const vectorLayer = new layer.Vector({
      source: vectorSource,
      style: createDrawStyle()
    })

    const osmLayer = new layer.Tile({
      source: new OSM()
    })

    const view = new View({
      zoom: 10,
      projection,
      center: [106.56, 29.57]
    })

    const map = new Map({
      target: 'draw',
      layers: [osmLayer, vectorLayer],
      view,
      controls: []
    })
    const draw = new interaction.Draw({
      type: 'Point',
      source: vectorSource
    })
    map.addInteraction(draw)
    mapRef.current = map
    drawRef.current = draw
  }, [])

  const handleClick = (event: React.ChangeEvent<HTMLInputElement>) => {
    setDrawType(event.target.value)
  }

  useEffect(() => {
    const map = mapRef.current!
    const source = drawSource.current!
    source.getFeatures().forEach(item => {
      if(item.getGeometry()?.getType() === drawType) {
        source.removeFeature(item)
      }
    })
    map.removeInteraction(drawRef.current!)
    const handleDrawEnd = (event: DrawEvent) => {
      const feature = event.feature
      feature.setProperties({
        type: drawType === 'Point' ? 'station' : 'line',
        name: drawType === 'Point' ? '站点' + i : '高速'
      })
      i++
    }
    const draw = new interaction.Draw({
      type: drawType,
      source: source,
      style: createDrawStyle()
    })
    draw.on('drawend', handleDrawEnd)
    drawRef.current = draw
    map.addInteraction(draw)
    return () => {
      draw.un('drawend', handleDrawEnd)
    }
  }, [drawType])

  const handleExport = () => {
    const source = drawSource.current!
    const features = source.getFeatures()
    const featureProjection = mapRef
      .current!.getView()
      .getProjection()
      .getCode()
    const jsonFormat = new format.GeoJSON({
      featureProjection,
      dataProjection: projection
    })

    const json = jsonFormat.writeFeatures(features, {
      featureProjection,
      dataProjection: projection
    })
    // 导出
    console.log(json, '>>>>')
  }

  return (
    <>
      <div
        style={{
          width: '800px',
          height: '400px',
          position: 'relative',
          display: 'flex'
        }}
      >
        <div id="draw" style={{ width: '800px', height: '400px' }}></div>
      </div>
      <input
        type="radio"
        checked={drawType === 'Point'}
        value={'Point'}
        onChange={handleClick}
      />{' '}
      添加站点
      <input
        type="radio"
        checked={drawType === 'LineString'}
        value={'LineString'}
        onChange={handleClick}
      />{' '}
      添加线路
      <button onClick={handleExport}>导出</button>
    </>
  )
}

export default DrawLine

当绘制好后可以点击导出、然后可以看到控制台有我们的JSON数据,这里我示例了一下,本来想找条真实的路,结果定位重庆就找不到一条直的路,算了。还有这里我们也可以手动便利features自己写json,能进一步巩固了解!

这是我绘制的供后面使用效果图如下:

4c2d8a92240a8a7ebe53730fd8dd0d35.png

OpenLayers 中使用刚才导入的数据

我们可以导入刚才的数据并加入一些交互,这里我对数据做了一些加工,这一步可以在编辑完成,但我们的编辑比较粗糙,我就手动对JSON做了编辑,主要功能:

  • 支持路线选中、显示路线信息
  • 支持站点选中、查看站点信息
import { useEffect, useRef, useState } from 'react'
import * as layer from 'ol/layer'
import * as source from 'ol/source'
import { Map } from 'ol'
import View from 'ol/View.js'
import OSM from 'ol/source/OSM'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Fill from 'ol/style/Fill'
import * as format from 'ol/format'
import CircleStyle from 'ol/style/Circle'

import * as interaction from 'ol/interaction'
import { pointerMove } from 'ol/events/condition'
const projection = 'EPSG:4326'


const createDrawStyle = () => {
  return new Style({
    image: new CircleStyle({
      radius: 4,
      fill: new Fill({
        color: 'red'
      })
    }),
    stroke: new Stroke({
      width: 2,
      color: '#000'
    })
  })
}
const DrawLine = () => {
  const mapRef = useRef<Map>(null)
  const wrapperRef = useRef<HTMLDivElement>(null)
  const drawSource = useRef<source.Vector>(null)
  const [active, setActive] = useState<any>(null)

  useEffect(() => {
    const vectorSource = new source.Vector({
      url: '/geo/cq.json',
      format: new format.GeoJSON()
    })
    drawSource.current = vectorSource
    const vectorLayer = new layer.Vector({
      source: vectorSource,
      style: createDrawStyle()
    })

    const osmLayer = new layer.Tile({
      source: new OSM()
    })

    const view = new View({
      zoom: 10,
      projection,
      center: [106.56, 29.57]
    })

    const map = new Map({
      target: 'draw',
      layers: [osmLayer, vectorLayer],
      view,
      controls: []
    })
    const select = new interaction.Select({
      condition: pointerMove,
      style: new Style({
        image: new CircleStyle({
          radius: 8,
          fill: new Fill({
            color: 'red'
          })
        }),
        stroke: new Stroke({
          width: 4,
          color: '#000'
        })
      })
    })
    map.addInteraction(select)
    map.on('pointermove', event => {
      const pixel = event.pixel;
      const features = map.getFeaturesAtPixel(pixel);
      if(features.length) {
        const feature = features.find(item => item.getGeometry()?.getType() === 'Point') || features[0]
        setActive({
          pixel,
          properties: feature.getProperties()
        })
      } else {
        setActive(null)
      }
    })
    mapRef.current = map
  }, [])

  return (
    <>
      <div
        style={{
          width: '800px',
          height: '400px',
          position: 'relative',
          display: 'flex'
        }}
      >
        <div id="draw" ref={wrapperRef} style={{ width: '800px', height: '400px', cursor: active ? 'pointer' : 'auto'  }}></div>
        {active && <div style={{
          width: '100px',
          background: '#fff',
          padding: '4px',
          borderRadius: '4px',
          position: 'absolute',
          left: active.pixel[0] + 20 + 'px',
          top: active.pixel[1] + 20 + 'px'
        }}>
          <h5>名称:{active.properties.name}</h5>
        </div>}
      </div>
    </>
  )
}

export default DrawLine

效果图如下

6b9ba06ac2c949da917c36dbe5e48919.png

这是我使用的JSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [
            106.40801847988175,
            29.57298086250036
          ],
          [
            106.56685851097549,
            29.580326066250358
          ],
          [
            106.69999032894425,
            29.54084559609411
          ],
          [
            106.80098688050674,
            29.43066753984411
          ],
          [
            106.83220399644425,
            29.410468229531606
          ],
          [
            106.88362042269425,
            29.403123025781607
          ],
          [
            106.91116493675675,
            29.406795627656606
          ],
          [
            106.96074506206925,
            29.417813433281605
          ]
        ]
      },
      "properties": {
        "type": "line",
        "name": "成渝高速"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.70012461444661,
          29.539872075705606
        ]
      },
      "properties": {
        "type": "station",
        "name": "安康"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.56685698617343,
          29.57991493114919
        ]
      },
      "properties": {
        "type": "station",
        "name": "巴中"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.88282014240801,
          29.40285042973459
        ]
      },
      "properties": {
        "type": "station",
        "name": "渝北"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.96102884444625,
          29.417240830909627
        ]
      },
      "properties": {
        "type": "station",
        "name": "简阳"
      }
    }
  ]
}

总结

通过本文的深入探索,我们理解了 GeoJSON 作为 Web 地理空间数据通用语言的核心优势:

  • 简洁: 基于 JSON 格式,结构清晰,易于人机阅读和编写。
  • 灵活: 强大的 Feature 和 FeatureCollection 结构允许我们将复杂的地理几何图形(如 LineString, Polygon, MultiPolygon)与丰富的非地理属性数据 (properties) 完美结合。
  • 标准化: 统一的 WGS84 坐标系和严格的规范(如右手法则),确保了 GeoJSON 文件在不同平台和 Web 地图库之间的互操作性。

JavaScript 原型链:理解对象继承的核心机制

JavaScript 原型链:理解对象继承的核心机制

在 JavaScript 中,原型链(Prototype Chain) 是实现对象继承和属性查找的核心机制。与传统面向对象语言(如 Java、C++)基于“类”的继承不同,JavaScript 采用的是 基于原型的继承模型。本文将结合 Promise 实例和普通构造函数示例,深入浅出地解析原型链的工作原理。


一、什么是原型链?

每个 JavaScript 对象(除 null 外)内部都有一个隐藏属性 [[Prototype]](可通过 __proto__ 访问),它指向另一个对象——这个对象就是该对象的“原型”。当试图访问一个对象的属性时,如果该对象自身没有这个属性,JavaScript 引擎会沿着原型链向上查找,直到找到该属性或到达原型链的末端(即 null)。

关键点:JavaScript 的继承不是靠“血缘”,而是靠“链条”——原型链。


二、构造函数、原型对象与实例的关系

以自定义构造函数为例:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.species = '人';

let zeng = new Person('jw', 18);
  • Person 是一个构造函数。
  • Person.prototype 是一个普通对象,所有通过 new Person() 创建的实例都会以它为原型。
  • zeng.__proto__ === Person.prototype成立
  • Person.prototype.constructor === Person成立

这种结构形成了经典的“三角关系”:

  • 实例(zeng)通过 __proto__ 指向原型对象(Person.prototype
  • 原型对象通过 constructor 指回构造函数(Person

🚂 比喻:可以把 constructor 看作火车头,prototype 是车身,而每个实例是挂在车身后的车厢。它们通过“挂钩”(__proto__)连接在一起。


三、动态修改原型:打破常规

JavaScript 的原型是可变的。我们可以随时修改对象的 __proto__

const kong = {
  name: 'kong',
  hobbies: ['篮球', '足球'],
};

zeng.__proto__ = kong;
console.log(zeng.hobbies); // ['篮球', '足球']
console.log(zeng.species); // undefined

此时:

  • zeng 不再从 Person.prototype 继承属性;
  • 而是从 kong 对象继承;
  • 因此 species 找不到了,但 hobbies 可以访问。

⚠️ 注意:虽然技术上可行,但不推荐随意修改 __proto__ ,因为它会破坏性能优化,并可能导致代码难以维护。


四、内置对象也遵循原型链:以 Promise 为例

ES6 引入的 Promise 同样遵循原型链规则:

const p = new Promise((resolve, reject) => {
  setTimeout(() => reject('失败1'), 3000);
});
  • p 是一个 Promise 实例;
  • p.__proto__ === Promise.prototypetrue
  • Promise.prototype 上定义了 .then(), .catch(), .finally() 等方法;
  • 所以 p.then(...) 实际上调用的是 Promise.prototype.then

执行流程:

  1. new Promise(...) 立即执行 executor 函数(同步)→ 输出 '111'
  2. 主线程继续执行 → 输出 '222'p 的初始状态(pending)
  3. 3 秒后,reject('失败1') 触发状态变为 rejected
  4. 微任务队列中安排 .catch() 回调 → 输出 '失败1'
  5. .finally() 总是执行 → 输出 'finally'

这再次印证:所有对象的行为都依赖于其原型链上的方法


五、原型链的本质:属性查找机制

当你写 obj.method() 时,JavaScript 引擎会:

  1. obj 自身查找 method
  2. 若无,则查找 obj.__proto__
  3. 若仍无,继续查找 obj.__proto__.__proto__
  4. ……直到 Object.prototype(最顶层)
  5. 若最终找不到,返回 undefined

例如:

zeng.toString(); // 虽然 zeng 自身没有 toString,但 Object.prototype 有

因为:

zeng 
→ __proto__ = kong 
→ __proto__ = Object.prototype 
→ has toString()

六、总结

概念 说明
__proto__ 实例指向其原型的链接(非标准但广泛支持)
prototype 构造函数的属性,用于被实例的 __proto__ 引用
constructor 原型对象上的属性,指回构造函数
原型链 属性/方法查找的路径,从实例 → 原型 → 原型的原型 → … → null

JavaScript 的面向对象不是靠“类继承”,而是靠“对象委托”——你没有的,我帮你问我的原型要。这种灵活而强大的机制,正是 JavaScript 动态特性的基石。

✅ 牢记:一切皆对象,万物皆可链。理解原型链,就掌握了 JavaScript 面向对象的灵魂。

判断dom元素是否在可视区域的常规方式

1. Intersection Observer API(推荐)

这是现代浏览器推荐的方法,性能最好,异步执行,不会阻塞主线程。

基础用法

// 创建观察器
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素进入可视区域', entry.target);
      // 可以在这里执行懒加载等操作
      entry.target.classList.add('visible');
    } else {
      console.log('元素离开可视区域', entry.target);
      entry.target.classList.remove('visible');
    }
  });
});

// 观察元素
const elements = document.querySelectorAll('.watch-element');
elements.forEach(el => observer.observe(el));

高级配置

const options = {
  // root: 指定根元素,默认为浏览器视窗
  root: null, // 或者指定特定元素,如 document.querySelector('.container')
  
  // rootMargin: 根的外边距,可以扩大或缩小根的边界框
  rootMargin: '10px 0px -100px 0px', // 上右下左,类似CSS margin
  
  // threshold: 触发回调的可见比例
  threshold: [0, 0.25, 0.5, 0.75, 1] // 在0%, 25%, 50%, 75%, 100%可见时触发
};

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const visiblePercentage = Math.round(entry.intersectionRatio * 100);
    console.log(`元素可见 ${visiblePercentage}%`);
    
    // 根据可见比例执行不同操作
    if (entry.intersectionRatio > 0.5) {
      // 超过50%可见
      entry.target.classList.add('mostly-visible');
    }
  });
}, options);

实用工具函数

// 封装的工具函数
function createVisibilityObserver(options = {}) {
  const defaultOptions = {
    root: null,
    rootMargin: '0px',
    threshold: 0.1
  };
  
  const finalOptions = { ...defaultOptions, ...options };
  
  return new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      const element = entry.target;
      const isVisible = entry.isIntersecting;
      
      // 触发自定义事件
      element.dispatchEvent(new CustomEvent('visibilityChange', {
        detail: { isVisible, entry }
      }));
    });
  }, finalOptions);
}

// 使用示例
const observer = createVisibilityObserver({ threshold: 0.5 });

document.querySelectorAll('.lazy-load').forEach(element => {
  observer.observe(element);
  
  element.addEventListener('visibilityChange', (e) => {
    if (e.detail.isVisible) {
      // 执行懒加载
      const img = element.querySelector('img[data-src]');
      if (img) {
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
      }
    }
  });
});

2. getBoundingClientRect() 方法

传统方法,同步执行,需要手动调用。

基础用法

function isInViewport(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const windowWidth = window.innerWidth || document.documentElement.clientWidth;
  
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= windowHeight &&
    rect.right <= windowWidth
  );
}

// 使用示例
const element = document.querySelector('.target');
if (isInViewport(element)) {
  console.log('元素完全在视窗内');
}

部分可见判断

function isPartiallyInViewport(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const windowWidth = window.innerWidth || document.documentElement.clientWidth;
  
  return (
    rect.bottom > 0 &&
    rect.top < windowHeight &&
    rect.right > 0 &&
    rect.left < windowWidth
  );
}

// 更详细的可见性信息
function getVisibilityInfo(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight;
  const windowWidth = window.innerWidth;
  
  // 计算可见区域
  const visibleTop = Math.max(0, rect.top);
  const visibleLeft = Math.max(0, rect.left);
  const visibleBottom = Math.min(windowHeight, rect.bottom);
  const visibleRight = Math.min(windowWidth, rect.right);
  
  const visibleWidth = Math.max(0, visibleRight - visibleLeft);
  const visibleHeight = Math.max(0, visibleBottom - visibleTop);
  const visibleArea = visibleWidth * visibleHeight;
  const totalArea = rect.width * rect.height;
  
  return {
    isVisible: visibleArea > 0,
    isFullyVisible: isInViewport(element),
    visibilityRatio: totalArea > 0 ? visibleArea / totalArea : 0,
    rect: rect,
    visibleArea: { width: visibleWidth, height: visibleHeight }
  };
}

// 使用示例
const element = document.querySelector('.target');
const info = getVisibilityInfo(element);
console.log(`可见比例: ${(info.visibilityRatio * 100).toFixed(2)}%`);

滚动监听版本

class ScrollVisibilityTracker {
  constructor(options = {}) {
    this.elements = new Map();
    this.threshold = options.threshold || 0.1;
    this.throttleDelay = options.throttleDelay || 100;
    
    this.checkVisibility = this.throttle(this.checkVisibility.bind(this), this.throttleDelay);
    this.bindEvents();
  }
  
  observe(element, callback) {
    this.elements.set(element, {
      callback,
      wasVisible: false
    });
    
    // 初始检查
    this.checkElement(element);
  }
  
  unobserve(element) {
    this.elements.delete(element);
  }
  
  checkVisibility() {
    this.elements.forEach((data, element) => {
      this.checkElement(element);
    });
  }
  
  checkElement(element) {
    const data = this.elements.get(element);
    if (!data) return;
    
    const info = getVisibilityInfo(element);
    const isVisible = info.visibilityRatio >= this.threshold;
    
    if (isVisible !== data.wasVisible) {
      data.wasVisible = isVisible;
      data.callback(isVisible, info);
    }
  }
  
  bindEvents() {
    window.addEventListener('scroll', this.checkVisibility, { passive: true });
    window.addEventListener('resize', this.checkVisibility);
  }
  
  destroy() {
    window.removeEventListener('scroll', this.checkVisibility);
    window.removeEventListener('resize', this.checkVisibility);
    this.elements.clear();
  }
  
  throttle(func, delay) {
    let timeoutId;
    let lastExecTime = 0;
    
    return function (...args) {
      const currentTime = Date.now();
      
      if (currentTime - lastExecTime > delay) {
        func.apply(this, args);
        lastExecTime = currentTime;
      } else {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
          func.apply(this, args);
          lastExecTime = Date.now();
        }, delay - (currentTime - lastExecTime));
      }
    };
  }
}

// 使用示例
const tracker = new ScrollVisibilityTracker({ threshold: 0.5 });

document.querySelectorAll('.track-element').forEach(element => {
  tracker.observe(element, (isVisible, info) => {
    if (isVisible) {
      element.classList.add('in-view');
      console.log('元素进入视窗', info);
    } else {
      element.classList.remove('in-view');
    }
  });
});

3. 特殊场景的解决方案

在滚动容器中的元素

function isInScrollContainer(element, container) {
  const elementRect = element.getBoundingClientRect();
  const containerRect = container.getBoundingClientRect();
  
  return (
    elementRect.top >= containerRect.top &&
    elementRect.left >= containerRect.left &&
    elementRect.bottom <= containerRect.bottom &&
    elementRect.right <= containerRect.right
  );
}

// 使用Intersection Observer观察滚动容器
function createContainerObserver(container) {
  return new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      console.log('容器内元素可见性变化', entry.isIntersecting);
    });
  }, {
    root: container, // 指定容器为根元素
    threshold: 0.1
  });
}

考虑CSS Transform的情况

function getTransformedBounds(element) {
  const rect = element.getBoundingClientRect();
  
  // 如果元素有CSS transform,getBoundingClientRect已经包含了变换后的位置
  // 不需要额外计算
  return rect;
}

// 对于复杂的3D变换,可能需要更精确的计算
function isTransformedElementVisible(element) {
  const rect = element.getBoundingClientRect();
  
  // 检查元素是否因为transform: scale(0)等而不可见
  const computedStyle = getComputedStyle(element);
  const transform = computedStyle.transform;
  
  if (transform === 'none') {
    return isPartiallyInViewport(element);
  }
  
  // 检查是否有scale(0)或类似的变换
  if (rect.width === 0 || rect.height === 0) {
    return false;
  }
  
  return isPartiallyInViewport(element);
}

4. 性能优化技巧

虚拟滚动场景

class VirtualScrollObserver {
  constructor(container, options = {}) {
    this.container = container;
    this.itemHeight = options.itemHeight || 100;
    this.buffer = options.buffer || 5; // 缓冲区项目数量
    this.items = [];
    this.visibleRange = { start: 0, end: 0 };
    
    this.handleScroll = this.throttle(this.calculateVisibleRange.bind(this), 16);
    this.container.addEventListener('scroll', this.handleScroll);
  }
  
  calculateVisibleRange() {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;
    
    const start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer);
    const end = Math.min(
      this.items.length - 1,
      Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.buffer
    );
    
    if (start !== this.visibleRange.start || end !== this.visibleRange.end) {
      this.visibleRange = { start, end };
      this.onVisibleRangeChange(this.visibleRange);
    }
  }
  
  onVisibleRangeChange(range) {
    // 子类实现或通过回调处理
    console.log('可见范围变化:', range);
  }
  
  throttle(func, delay) {
    let lastTime = 0;
    return function(...args) {
      const now = Date.now();
      if (now - lastTime >= delay) {
        func.apply(this, args);
        lastTime = now;
      }
    };
  }
}

懒加载图片完整实现

class LazyImageLoader {
  constructor(options = {}) {
    this.options = {
      rootMargin: '50px',
      threshold: 0.1,
      loadingClass: 'lazy-loading',
      loadedClass: 'lazy-loaded',
      errorClass: 'lazy-error',
      ...options
    };
    
    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      {
        rootMargin: this.options.rootMargin,
        threshold: this.options.threshold
      }
    );
    
    this.loadingImages = new Set();
  }
  
  observe(img) {
    if (!(img instanceof HTMLImageElement)) {
      console.warn('LazyImageLoader: 只能观察img元素');
      return;
    }
    
    if (!img.dataset.src && !img.dataset.srcset) {
      console.warn('LazyImageLoader: 图片缺少data-src或data-srcset属性');
      return;
    }
    
    this.observer.observe(img);
  }
  
  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        this.loadImage(entry.target);
        this.observer.unobserve(entry.target);
      }
    });
  }
  
  loadImage(img) {
    if (this.loadingImages.has(img)) return;
    
    this.loadingImages.add(img);
    img.classList.add(this.options.loadingClass);
    
    const tempImg = new Image();
    
    tempImg.onload = () => {
      this.applyImage(img, tempImg);
      img.classList.remove(this.options.loadingClass);
      img.classList.add(this.options.loadedClass);
      this.loadingImages.delete(img);
    };
    
    tempImg.onerror = () => {
      img.classList.remove(this.options.loadingClass);
      img.classList.add(this.options.errorClass);
      this.loadingImages.delete(img);
    };
    
    // 支持srcset
    if (img.dataset.srcset) {
      tempImg.srcset = img.dataset.srcset;
    }
    tempImg.src = img.dataset.src;
  }
  
  applyImage(img, tempImg) {
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset;
      delete img.dataset.srcset;
    }
    img.src = tempImg.src;
    delete img.dataset.src;
  }
  
  destroy() {
    this.observer.disconnect();
    this.loadingImages.clear();
  }
}

// 使用示例
const lazyLoader = new LazyImageLoader({
  rootMargin: '100px',
  threshold: 0.1
});

document.querySelectorAll('img[data-src]').forEach(img => {
  lazyLoader.observe(img);
});

5. 兼容性处理

// Intersection Observer polyfill检查
function createCompatibleObserver(callback, options) {
  if ('IntersectionObserver' in window) {
    return new IntersectionObserver(callback, options);
  } else {
    console.warn('IntersectionObserver not supported, falling back to scroll listener');
    return createScrollBasedObserver(callback, options);
  }
}

function createScrollBasedObserver(callback, options = {}) {
  const elements = new Set();
  const threshold = options.threshold || 0;
  
  function checkElements() {
    const entries = [];
    
    elements.forEach(element => {
      const info = getVisibilityInfo(element);
      const isIntersecting = info.visibilityRatio >= threshold;
      
      entries.push({
        target: element,
        isIntersecting,
        intersectionRatio: info.visibilityRatio,
        boundingClientRect: info.rect
      });
    });
    
    if (entries.length > 0) {
      callback(entries);
    }
  }
  
  const throttledCheck = throttle(checkElements, 100);
  
  window.addEventListener('scroll', throttledCheck, { passive: true });
  window.addEventListener('resize', throttledCheck);
  
  return {
    observe(element) {
      elements.add(element);
      // 立即检查一次
      setTimeout(() => {
        const info = getVisibilityInfo(element);
        const isIntersecting = info.visibilityRatio >= threshold;
        callback([{
          target: element,
          isIntersecting,
          intersectionRatio: info.visibilityRatio,
          boundingClientRect: info.rect
        }]);
      }, 0);
    },
    
    unobserve(element) {
      elements.delete(element);
    },
    
    disconnect() {
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
      elements.clear();
    }
  };
}

总结

选择合适的方法:

  1. Intersection Observer API - 现代浏览器首选,性能最佳
  2. getBoundingClientRect + 滚动监听 - 需要兼容老浏览器时使用
  3. 虚拟滚动 - 处理大量元素时的特殊优化

关键考虑因素:

  • 性能:Intersection Observer > 节流的滚动监听 > 频繁的滚动监听
  • 精确度:getBoundingClientRect更精确,但需要手动触发
  • 兼容性:getBoundingClientRect支持更老的浏览器
  • 功能需求:是否需要部分可见、可见比例等详细信息

写着写着,就踩进了 JavaScript 的小坑

很多人学 JS,是从“能把需求写出来”开始的。
但真正在面试或者写复杂业务时,常见的几个小坑——数组的 mapNaN 与 Infinity、字符串的包装类和长度问题——经常一起出来“围殴”你。

这篇文章不讲大而全,只用几组小实验,把它们串成一条线:
从数组遍历,到数字解析,再到字符串与 emoji 的长度。

一、数组不只是 formap 才是更现代的写法

最传统的遍历数组,往往是这样:

const arr = [1, 2, 3, 4, 5, 6];
const result = [];
for (let i = 0; i < arr.length; i++) {
  result.push(arr[i] * arr[i]);
}
console.log(result); // [1, 4, 9, 16, 25, 36]

而在更现代的写法里,我们会让数组自带的方法来负责“遍历 + 映射”:

const arr = [1, 2, 3, 4, 5, 6];
console.log(arr.map(item => item * item)); 
// [1, 4, 9, 16, 25, 36]

特点:

  • 原数组不变,返回一个新数组
  • 更符合“声明式”的风格:告诉它“要做什么”,而不是“怎么做”

理解 map 的回调函数签名也很重要:

array.map(function (item, index, arr) {
  // item  当前元素
  // index 当前索引
  // arr   整个原数组
});

只要记住这三个参数的顺序,你在后面就能看懂更多“骚操作”。

二、当 map 遇上数字解析:NaN 是怎么溜进来的?

有了上面的铺垫,再看这一类代码就不陌生了:

const arr = [1, 2, 3];

arr.map(function (item, index, source) {
  console.log(item, index, source);
  return item;
});

这一段可以帮助你记住参数顺序。
然后,在实际项目或面试题中,很容易有人写出类似:

[1, 2, 3].map(parseInt);

这个例子本身已经很有名了,这里只强调两点:

  • 回调函数拿到的是 (item, index, arr)
  • 数字解析函数的签名是 (string, radix):要解析的字符串 + 进制

当两边的参数顺序撞在一起时,就有了进制被错误传入的问题,于是 NaN 出现了。

关键不是记住“这题的答案”,
而是要记住:数组方法的回调长什么样,别乱用现成函数往上一丢就图省事。

三、NaN 和 Infinity:数字类型里的“异类”

在处理数字时,还有两个非常容易被忽视的存在:

console.log(0 / 0);   // NaN
console.log(6 / 0);   // Infinity
console.log(-6 / 0);  // -Infinity

以及:

console.log(parseInt("108"));       // 108
console.log(parseInt("八百108"));   // NaN
console.log(parseInt("108八百"));   // 108
console.log(parseInt(1314.520));    // 1314

可以总结出几个有用的直觉:

  • NaN
    表示“这不是一个合法的数字结果”,典型场景是:

    • 0 / 0
    • 完全看不懂的字符串解析(比如一上来就是汉字)
  • Infinity / -Infinity
    表示正负无穷大,例如除以 0。

  • parseInt 的解析习性

    • 从左往右看,一开始就看不懂 → 整体 NaN
    • 先看懂了一段,中途遇到不认识的字符 → 前面合法的部分照算
    • 遇到小数 → 小数点后面直接不要

再加上一句经典但反直觉的事实:

typeof NaN === "number"; // true

这就是为什么很多 JS 教程会专门开一小节来讲“特殊数字类型”。

四、看起来“一切皆对象”,其实 JS 在背后帮你擦屁股

有一个常被忽略、但又极其常用的能力:

"hello".length;         // 5
(520.1314).toFixed(2);  // "520.13"

从传统面向对象的思路看:

  • 字符串字面量、数字字面量都属于原始值
  • 原始值照理说不是对象,不能随便点属性、调方法

但在这门语言里,你天天在这么写,而且完全没报错。
原因是引擎偷偷给你做了**“包装”**:

  • 原始字符串 → 临时变成 String 对象
  • 原始数字 → 临时变成 Number 对象
  • 原始布尔值 → 临时变成 Boolean 对象

可以用一组简化版的伪操作来理解:

var str = "hello";

str.length; 

// 底层会做类似下面的事:
var strObj = new String(str);
console.log(strObj.length); // 5
strObj = null;              // 用完扔掉

console.log(typeof str);    // "string"(原始类型没改变)

也就是说:

  • 表面上是“统一风格,一切皆能点属性、调方法”
  • 实际上是引擎在背后帮你 new 来 new 去

这也解释了为什么这门语言经常被说“很傻瓜化”:
为了让你写起来爽,它会帮你兜很多底。

五、字符串长度与 emoji:肉眼看到的“一个”不等于 length === 1

再来看一组和字符串相关的实验:

js
console.log("a".length);   // 1
console.log("中".length);  // 1
console.log("𝄞".length);   // 2(看起来一个符号,却占了两个长度单位)

在这门语言里,字符串底层使用 UTF-16 编码

  • 大部分常见字符用 一个 16 位单元 表示 → length 加 1
  • 某些生僻字和 emoji 用 两个 16 位单元 表示 → length 加 2

再配合一段稍微综合一点的示例:

const str = " Hello, 世界! 👋  ";

console.log(str.length);                      // 包含空格、中文、emoji 在内的长度
console.log(str[1]);                          // 访问第二个 UTF-16 单元
console.log(str.charAt(1), str.charAt(1) == str[1]); // 在常规字符上二者表现一致
console.log(str.slice(1, 6));                 // 截取 [1, 6) 区间
console.log(str.substring(1, 6));             // 在这个用法下和 slice 表现一样
```](cascade:incomplete-link)

你可以得到两个非常有用的结论:

  • length 表示的是UTF-16 单元数量,不是“肉眼看到的字符个数”
  • 对英文、常见汉字,大多数时候可以“假装没区别”
  • 一旦大量使用 emoji 或特殊字符,索引和截取就可能和视觉表现错位

在做以下需求时,一定要记住这一点:

  • 限制“输入最多 N 个字符”
  • 截断字符串用于展示(例如列表项缩略显示)
  • 按“字符数”计费、统计、对齐

否则,emoji 往往会成为你 UI 中最顽皮的那一块。

六、把这些零散知识点连成一条线

回头看看前面的几个小实验,它们其实在回答同一类问题:

“这门语言,为了让你写起来看起来简单,到底在底层帮你做了多少事?”

  • 数组方法
    map 不只是简化了 for 循环,还规定了固定的回调参数顺序,
    一不留神用错现成函数,就会引入莫名其妙的 NaN
  • 数字解析与特殊值
    parseInt 的解析规则、NaN 和 Infinity 的存在,都在提醒你:
    “看起来是数字,其实里面有很多状态要区分”。
  • 包装类
    "hello".length(520.1314).toFixed(2) 这种写法之所以成立,
    是因为底层在帮你悄悄构造临时对象。
  • 字符串与编码
    length 和字符视觉上的“个数”不总是对得上的,
    emoji 是检验你有没有意识到这一点的最好测试用例。

当你愿意停下来,用几分钟时间亲手敲一遍这些代码,
并且追问每一行输出背后的“为什么”,
你就已经不只是“会写这门语言”,
而是在慢慢“理解这门语言”。

反转字符串与两数之和:两道简单题背后的 JavaScript 思维深度

面试官的屏幕上跳出一个简单的问题:“写一个函数,反转字符串。”候选人微微一笑,这太简单了。但当他开始思考时,才发现这简单的题目下藏着 JavaScript 语言特性的深海。

从直觉开始:为何要多种解法?

如果你认为字符串反转只是一个 reverse() 的事,那么你可能错过了面试官真正想看到的东西。在一个真实的技术面试中,面试官关心的往往不是你能否写出代码,而是你如何思考代码

“请反转字符串‘hello’。”面试官说。

第一个闪现的思路自然是 JavaScript 的内置方法:

function reverseStr(str) {
    return str.split('').reverse().join('');
}

“很好,”面试官点头,“但如果不让你用 reverse() 呢?”

思维的转换:从 API 到算法

这时,面试进入了一个微妙的阶段。候选人必须展示他不仅知道如何使用工具,还理解工具背后的原理。

循环方法是最直接的替代方案:

function reverseStr(str) {
    let reversed = '';
    for (let i = str.length - 1; i >= 0; i--) {
        reversed += str[i];
    }
    return reversed;
}

这个解法朴素、直接,像一位诚实的工匠,一步一步地完成工作。但现代 JavaScript 提供了更优雅的表达方式。ES6 的 for...of 让代码读起来像散文:

function reverseStr(str) {
    let reversed = '';
    for (const char of str) {
        reversed = char + reversed; // 注意这里的顺序
    }
    return reversed;
}

顺序!这里藏着一个微妙的点:char + reversed 而不是 reversed + char。这个小小的细节区分了理解与背诵。

深入语言特性:JavaScript 的表达力

随着对话深入,面试官想看看候选人对 JavaScript 现代特性的掌握程度。

“试试用函数式的方法?”面试官提议。

reduce 登场了,这个看似复杂的数组方法在这里找到了完美的应用场景:

function reverseStr(str) {
    return [...str].reduce((acc, char) => char + acc, '');
}

短短一行代码,浓缩了 JavaScript 的精华:展开运算符、箭头函数、reduce 的累积逻辑。这种解法不仅展示了技术能力,更展示了代码的品味

“有趣的是,”候选人补充道,“这里用 [...str] 代替 str.split(''),不仅能正确处理大多数 Unicode 字符,代码也更加简洁。”

思维的抽象:数据结构视角

真正区分初级和高级开发者的,往往是对数据结构的理解。字符串反转可以被看作一个栈操作

function reverseStr(str) {
    const stack = [];
    for (let char of str) {
        stack.push(char);
    }
    
    let reversed = '';
    while (stack.length > 0) {
        reversed += stack.pop(); // 后进先出,自然反转
    }
    return reversed;
}

栈是后进先出(LIFO)的,这恰好是反转的天然特性。同样,用队列也能解决,但需要一点技巧:

function reverseStr(str) {
    const queue = str.split('');
    let reversed = '';
    while (queue.length > 0) {
        reversed = queue.shift() + reversed; // 每次都加到前面
    }
    return reversed;
}

“不过,”候选人诚实地说,“队列解法的性能不如栈,因为 shift() 操作是 O(n) 复杂度。”

这种诚实的技术判断,往往比单纯展示知识更重要。

算法的优化:双指针思维

当字符串很长时,效率变得重要。双指针法展示了算法优化的思维:

function reverseStr(str) {
    const arr = str.split('');
    let left = 0;
    let right = arr.length - 1;

    while (left < right) {
        [arr[left], arr[right]] = [arr[right], arr[left]];
        left++;
        right--;
    }

    return arr.join('');
}

这里最精妙的是那行 ES6 的解构赋值交换。在 JavaScript 中,字符串是不可变的,但我们可以先转为数组,在数组中“原地”操作,最后再转回字符串。

“这种解法的好处是,”候选人解释道,“它只需要遍历一半的数组,并且交换操作是常量时间复杂度。”

思维的边界:递归的风险与美

在技术面试中,提到递归往往能引发深入的讨论:

function reverseStr(str) {
    if (str.length <= 1) {
        return str;
    }
    return reverseStr(str.slice(1)) + str[0];
}

递归的美在于它的声明式表达:反转一个字符串等于反转它的子串加上第一个字符。但美中不足的是风险——JavaScript 的调用栈是有限的。

“在实际项目中,”候选人说,“我只会对确定很短的字符串使用递归,或者使用尾递归优化——虽然 JavaScript 引擎的尾调用优化支持还不够普遍。”

这种对技术限制的清醒认识,是经验丰富的标志。

回归现实:在面试中如何选择

“那么,”面试官最后问,“在实际编码中,你会选择哪种方法?”

最佳答案可能是:“看情况。”

  1. 日常开发中,str.split('').reverse().join('')[...str].reverse().join('') 是最佳选择:可读、简洁、性能足够。
  2. 代码审查时,可能会讨论用展开运算符处理 Unicode 字符更准确。
  3. 性能敏感场景,考虑双指针法减少操作次数。
  4. 教学场景中,展示多种解法可以帮助理解不同编程范式。

这时,面试官突然转换了话题:“让我们看另一个问题——两数之和。给定一个数组和目标和,找出数组中和为目标的两个数的索引。”

两数之和:从暴力到优化

第一个冲动是暴力解法:

function twoSum(nums, target) {
    for(let i = 0; i < nums.length; i++) {
        for(let j = i + 1; j < nums.length; j++) {
            if(nums[i] + nums[j] === target) {
                return [i, j];
            }
        }
    }
}

“O(n²)的时间复杂度,”你承认,“对于小数组可以,但数据量大时就不行了。”

“如何优化?”面试官追问。

你停顿了一下,想到了关键思维转换:“把求和问题变成求差问题。”

思维的跃迁:从数学转换到数据结构

“与其遍历所有组合寻找两个数之和等于目标,不如遍历一次,对于每个数,计算它与目标的差值,然后检查这个差值是否出现过。”

你开始在白板上画图:“这就需要一种数据结构,能快速查找——哈希表。”

ES5风格的哈希实现

function twoSum(nums, target) {
    const diffs = {}; // 简单的对象作为哈希表
    for(let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if(diffs[complement] !== undefined) {
            return [diffs[complement], i];
        }
        diffs[nums[i]] = i;
    }
}

“这是O(n)的时间复杂度,”你解释,“我们用空间换时间。对象存储键值对,键是数组值,值是索引。”

但你还没说完:“不过,ES6提供了更专业的工具——”

两数之和的现代实现

function twoSum(nums, target) {
    const diffs = new Map();
    for (let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if (diffs.has(complement)) {
            return [diffs.get(complement), i];
        }
        diffs.set(nums[i], i);
    }
}

“Map比普通对象有几个优势,”你详细说明,“键可以是任意类型,而对象只能是字符串或Symbol;Map保持插入顺序;有更清晰的API如has()get()set()。”

更大的图景:两道题背后的思维方式

反转字符串与两数之和之所以成为经典的面试题,是因为它们像一对互补的棱镜,共同折射出开发者完整的能力光谱:

两道题目共同考察的能力维度

  1. 语言掌握深度:从反转字符串的展开运算符[...str]到两数之和的Map数据结构,表面是考察API熟悉度,实则是测试对语言特性演进的理解——为何ES6的Map比普通对象更适合作为哈希表?为何展开运算符比split()更现代?

  2. 算法思维层次:反转字符串让我们思考“顺序逆变换”的多种实现路径;两数之和则挑战我们将“求和问题”转化为“查找问题”的抽象能力。前者考察同一问题的多解视角,后者测试问题本质的重构能力。

  3. 代码品味与工程权衡:在反转字符串中,简洁的API调用与手写双指针之间如何选择?在两数之和中,何时用对象作为哈希表,何时必须用Map?这些选择背后,是对可读性、性能、兼容性、维护成本的多维度权衡。

  4. 思维过程的透明化:面试官不在乎你是否瞬间给出最优解,而在乎你能否清晰陈述从暴力解法到优化方案的思考路径——为何想到用哈希表?为何选择空间换时间?这种将内在思维外化的能力,才是团队协作的核心。

系统化思维的完整呈现

最终,面试官通过这两道题观察的,是你是否建立了解决问题的系统化思维框架。当你能从反转字符串的多种解法中,识别出“数据结构视角”与“算法优化视角”的差异;当你能在两数之和的优化过程中,明确解释“问题转化”与“数据结构选择”的逻辑链条——你已经证明了自己不只是记忆解决方案的编码员,而是能够分析问题本质、设计解决路径、评估方案优劣的工程思考者。

反转字符串让你展示思维的深度——对一个简单问题能想到多深;两数之和让你展示思维的转化力——如何将复杂问题转化为已知模式。二者的结合,恰好构成了技术思维的两个关键维度:垂直的专业深度与水平的问题抽象能力。

下次当你面对看似简单的面试题时,记住:题目只是载体,真正被测试的是你构建思考框架、进行技术决策、清晰表达逻辑的综合能力。在技术的世界里,这种系统化思考的能力,远比记忆任何特定解法都更能定义你的长期价值。

如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣)

image.png

大家好😁。

上个月,我们公司的内部敏感文档(PRD)截图,竟然出现在了竞品的群里。

老板大发雷霆,要求技术部彻查:到底是谁泄露出去的?😠

但问题是,文档是纯文本的,截图上也没有任何显式的水印(那种写着员工名字的大黑字,太丑了,产品经理也不让加)。

怎么查?

这时候,我默默地打开了我的VS Code,给老板演示了一个技巧

老板,其实泄露的那段文字里,藏着那个人的工号,只是你肉眼看不见。

今天,我就来揭秘这个技术——基于零宽字符(Zero Width Characters)的盲水印技术。学会这招,你也能给你的页面加上隐形追踪器。


先科普一下,什么叫零宽字符?

在Unicode字符集中,有一类神奇的字符。它们存在,但不占用任何宽度,也不显示任何像素

简单说,它们是隐形的。

最常见的几个:

  • \u200b (Zero Width Space):零宽空格
  • \u200c (Zero Width Non-Joiner):零宽非连字符
  • \u200d (Zero Width Joiner):零宽连字符

我们可以在Chrome控制台里试一下:

console.log('A' + '\u200b' + 'B');
// 输出: "AB"
// 看起来和普通的 "AB" 一模一样

但是,如果我们检查它的长度:

console.log(('A' + '\u200b' + 'B').length);
// 输出: 3

看到没?😁

image.png


它的原理是什么?

原理非常简单,就是利用这些隐形字符,把用户的信息(比如工号User_9527),编码进一段正常的文本里。

步骤如下:

  1. 准备密码本 :我们选两个零宽字符,代表二进制的 01

    • \u200b 代表 0
    • \u200c 代表 1
    • 再用 \u200d 作为分割符。
  2. 加密(编码)

    • 把工号字符串(如 9527)转成二进制。
    • 把二进制里的 0/1 替换成对应的零宽字符。
    • 把这串隐形字符串,插入到文档的文字中间。
  3. 解密(解码)

    • 拿到泄露的文本,提取出里面的零宽字符。
    • 把零宽字符还原成 0/1。
    • 把二进制转回字符串,锁定👉这个内鬼。

是不是很神奇?🤣


只需要30行代码实现抓内鬼工具

不废话,直接上代码。你可以直接复制到控制台运行。

加密函数 (Inject Watermark)

// 零宽字符字典
const zeroWidthMap = {
  '0': '\u200b', // Zero Width Space
  '1': '\u200c', // Zero Width Non-Joiner
};

function textToBinary(text) {
  return text.split('').map(char => 
    char.charCodeAt(0).toString(2).padStart(8, '0') // 转成8位二进制
  ).join('');
}

function encodeWatermark(text, secret) {
  const binary = textToBinary(secret);
  const hiddenStr = binary.split('').map(b => zeroWidthMap[b]).join('');
  
  // 将隐形字符,插入到文本的第一个字符后面
  // 你也可以随机分散插入,更难被发现
  return text.slice(0, 1) + hiddenStr + text.slice(1);
}

// === 测试 ===
const originalText = "公司机密文档,严禁外传!";
const userWorkId = "User_9527";

const watermarkText = encodeWatermark(originalText, userWorkId);

console.log("原文:", originalText);
console.log("带水印:", watermarkText);
console.log("肉眼看得出区别吗?", originalText === watermarkText); // false
console.log("长度对比:", originalText.length, watermarkText.length); 

image.png

image.png

当你把 watermarkText 复制到微信、飞书或者任何地方,那串隐形字符都会跟着一起被复制过去

解密函数的实现

现在,假设我们拿到了泄露出去的这段文字,怎么还原出是谁干的?

// 反向字典
const binaryMap = {
  '\u200b': '0',
  '\u200c': '1',
};

function decodeWatermark(text) {
  // 1. 提取所有零宽字符
  const hiddenChars = text.match(/[\u200b\u200c]/g);
  if (!hiddenChars) return '未发现水印';
  
  // 2. 转回二进制字符串
  const binaryStr = hiddenChars.map(c => binaryMap[c]).join('');
  
  // 3. 二进制转文本
  let result = '';
  for (let i = 0; i < binaryStr.length; i += 8) {
    const byte = binaryStr.slice(i, i + 8);
    result += String.fromCharCode(parseInt(byte, 2));
  }
  
  return result;
}

// === 测试抓内鬼 ===
const leakerId = decodeWatermark(watermarkText);
console.log("抓到内鬼工号:", leakerId); // 输出: User_9527

微信或者飞书 复制出来的文案 👇

image.png


这种水印能被清除吗?

当然可以,但前提是你知道它的存在

对于不懂技术的普通员工,他们复制粘贴文字时,根本不会意识到自己已经暴露了🤔

如果遇到了懂技术的内鬼,他可能会:

  1. 手动重打一遍文字:这样水印肯定就丢了(但这成本太高)🤷‍♂️
  2. 用脚本过滤:如果他知道你用了零宽字符,写个正则 text.replace(/[\u200b-\u200f]/g, '') 就能清除。

虽然它不是万能的,但它是一种极低成本、极高隐蔽性的防御手段。


技术本身就没什么善恶。

我分享这个技术,不是为了让你去监控谁,而是希望大家多掌握一种防御性编程的一个思路。

在Web开发中,除了明面上的UI和交互,还有很多像零宽字符这样隐秘的角落,藏着一些技巧。

下次如果面试官问你:除了显式的水印,你还有什么办法保护页面内容?

你可以自信地抛出这个方案,绝对能震住全场😁。

谢谢大家.gif

使劲折腾Element Plus的Table组件

背景

笔者公司的一个项目大量使用el-table组件,并做出一些魔改的效果

多列显示

废话不多讲,直接上效果

image.png

使用el-table组件的多级表头,不存在滴

核心代码如下

<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, Refresh, Edit, Delete, View } from '@element-plus/icons-vue'

interface User {
  id: number
  avatar: string
  username: string
  realName: string
  email: string
  phone: string
  gender: 'male' | 'female' | 'unknown'
  age: number
  department: string
  position: string
  status: 'active' | 'inactive' | 'banned'
  registerTime: string
  lastLoginTime: string
  province: string
  city: string
  address: string
  salary: number
  education: string
  workYears: number
}

const loading = ref(false)
const searchText = ref('')
const statusFilter = ref('')
const departmentFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)

const departments = ['技术部', '产品部', '设计部', '市场部', '运营部', '人事部', '财务部']
const positions = ['工程师', '高级工程师', '技术经理', '产品经理', '设计师', '运营专员', 'HR专员', '财务专员']
const educations = ['高中', '大专', '本科', '硕士', '博士']
const provinces = ['北京', '上海', '广东', '浙江', '江苏', '四川', '湖北']

const generateMockData = (): User[] => {
  const data: User[] = []
  for (let i = 1; i <= 100; i++) {
    data.push({
      id: i,
      avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
      username: `user${i}`,
      realName: `用户${i}`,
      email: `user${i}@example.com`,
      phone: `138${String(i).padStart(8, '0')}`,
      gender: ['male', 'female', 'unknown'][i % 3] as User['gender'],
      age: 20 + (i % 30),
      department: departments[i % departments.length],
      position: positions[i % positions.length],
      status: ['active', 'inactive', 'banned'][i % 3] as User['status'],
      registerTime: `2023-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 10:30:00`,
      lastLoginTime: `2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 14:20:00`,
      province: provinces[i % provinces.length],
      city: '市区',
      address: `街道${i}号`,
      salary: 8000 + (i % 20) * 1000,
      education: educations[i % educations.length],
      workYears: i % 15,
    })
  }
  return data
}

const allUsers = ref<User[]>(generateMockData())

const filteredUsers = computed(() => {
  let result = allUsers.value

  if (searchText.value) {
    const search = searchText.value.toLowerCase()
    result = result.filter(
      (user) =>
        user.username.toLowerCase().includes(search) ||
        user.realName.toLowerCase().includes(search) ||
        user.email.toLowerCase().includes(search) ||
        user.phone.includes(search)
    )
  }

  if (statusFilter.value) {
    result = result.filter((user) => user.status === statusFilter.value)
  }

  if (departmentFilter.value) {
    result = result.filter((user) => user.department === departmentFilter.value)
  }

  return result
})

const paginatedUsers = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return filteredUsers.value.slice(start, end)
})

const total = computed(() => filteredUsers.value.length)

const getGenderText = (gender: string) => {
  const map: Record<string, string> = {
    male: '男',
    female: '女',
    unknown: '未知',
  }
  return map[gender] || '未知'
}

const getStatusType = (status: string) => {
  const map: Record<string, string> = {
    active: 'success',
    inactive: 'warning',
    banned: 'danger',
  }
  return map[status] || 'info'
}

const getStatusText = (status: string) => {
  const map: Record<string, string> = {
    active: '正常',
    inactive: '未激活',
    banned: '已禁用',
  }
  return map[status] || '未知'
}

const handleSearch = () => {
  currentPage.value = 1
}

const handleReset = () => {
  searchText.value = ''
  statusFilter.value = ''
  departmentFilter.value = ''
  currentPage.value = 1
}

const handleView = (row: User) => {
  console.log('查看用户:', row)
}

const handleEdit = (row: User) => {
  console.log('编辑用户:', row)
}

const handleDelete = (row: User) => {
  console.log('删除用户:', row)
}

const handleSizeChange = (val: number) => {
  pageSize.value = val
  currentPage.value = 1
}

const handleCurrentChange = (val: number) => {
  currentPage.value = val
}

const formatSalary = (salary: number) => {
  return `¥${salary.toLocaleString()}`
}
</script>

<template>
  <div class="user-list-container">
    <el-card class="search-card">
      <el-form :inline="true" class="search-form">
        <el-form-item label="关键词">
          <el-input
            v-model="searchText"
            placeholder="用户名/姓名/邮箱/手机"
            clearable
            :prefix-icon="Search"
            @keyup.enter="handleSearch"
          />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="statusFilter" placeholder="全部" clearable style="width: 120px">
            <el-option label="正常" value="active" />
            <el-option label="未激活" value="inactive" />
            <el-option label="已禁用" value="banned" />
          </el-select>
        </el-form-item>
        <el-form-item label="部门">
          <el-select v-model="departmentFilter" placeholder="全部" clearable style="width: 120px">
            <el-option v-for="dept in departments" :key="dept" :label="dept" :value="dept" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card class="table-card">
      <el-table
        :data="paginatedUsers"
        v-loading="loading"
        border
        stripe
        highlight-current-row
        style="width: 100%"
        :header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
      >
        <el-table-column type="selection" width="50" fixed="left" />
        <el-table-column prop="id" label="ID" width="70" fixed="left" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.id }}
          </template>
        </el-table-column>
        <el-table-column label="头像" width="80">
          <template #default="{ row, $index }">
            <el-avatar v-if="$index !== 0" :size="40" :src="row.avatar" />
          </template>
        </el-table-column>
        <el-table-column prop="username" label="用户名" width="120" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.username }}
          </template>
        </el-table-column>
        <el-table-column prop="realName" label="姓名" width="100" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.realName }}
          </template>
        </el-table-column>
        <el-table-column prop="gender" label="性别" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : getGenderText(row.gender) }}
          </template>
        </el-table-column>
        <el-table-column prop="age" label="年龄" width="70" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.age }}
          </template>
        </el-table-column>
        <el-table-column prop="phone" label="手机号" width="130">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.phone }}
          </template>
        </el-table-column>
        <el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.email }}
          </template>
        </el-table-column>
        <el-table-column prop="department" label="部门" width="100">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.department }}
          </template>
        </el-table-column>
        <el-table-column prop="position" label="职位" width="120">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.position }}
          </template>
        </el-table-column>
        <el-table-column prop="education" label="学历" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.education }}
          </template>
        </el-table-column>
        <el-table-column prop="workYears" label="工龄" width="70" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : `${row.workYears}年` }}
          </template>
        </el-table-column>
        <el-table-column prop="salary" label="薪资" width="100" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : formatSalary(row.salary) }}
          </template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row, $index }">
            <span v-if="$index === 0">
              {{ '' }}
            </span>
            <el-tag v-else :type="getStatusType(row.status) as any">
              {{ getStatusText(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="province" label="" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '省份' : row.province }}
          </template>
        </el-table-column>
        <el-table-column prop="city" label="地址" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '市' : row.city }}
          </template>
        </el-table-column>
        <el-table-column prop="address" label="" width="120" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '街道' : row.address }}
          </template>
        </el-table-column>
        <el-table-column prop="registerTime" label="注册时间" width="170" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.registerTime }}
          </template>
        </el-table-column>
        <el-table-column prop="lastLoginTime" label="最后登录" width="170" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.lastLoginTime }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right">
          <template #default="{ row, $index }">
            <template v-if="$index !== 0">
              <el-button type="primary" link :icon="View" @click="handleView(row)">查看</el-button>
              <el-button type="warning" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
              <el-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(row)">
                <template #reference>
                  <el-button type="danger" link :icon="Delete">删除</el-button>
                </template>
              </el-popconfirm>
            </template>
          </template>
        </el-table-column>
      </el-table>

      <div class="pagination-container">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="[10, 20, 50, 100]"
          :total="total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
  </div>
</template>

<style scoped>
.user-list-container {
  padding: 20px;
}

.search-card {
  margin-bottom: 20px;
}

.search-form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.table-card {
  width: 100%;
}

.pagination-container {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}

:deep(.el-table__header-wrapper thead tr th:nth-of-type(16)) {
  border-right: 0;
}

:deep(.el-table__header-wrapper thead tr th:nth-of-type(17)) {
  border-right: 0;
}
</style>

陆续更新

❌