Lax源码解析

今天在翻 github 的 trending 的时候,发现今日 star 最多的库是lax这个动画库,而我平时对动画又比较喜欢去研究,所以就点进去看源码了。

接下来我就简单的解析下它的源码。

首先打开它的源码,可以看到源码总共也才 300 多行,应该说是比较精简的。

源码结构

(function () {
  var lax = function () {
    // 用来保存页面中应用lax的元素节点
    var lax = {
      elements: []
    };

    // 滚动时的上一个ScrollY
    var lastY = 0;

    /**
     * 应用transform的一些基本规则
     * 所有的动画都是基于这些规则
     * transforms的每个值都是一个函数
     *
     * @params {Object} style 样式对象
     * @params {Number, String} 某样式的value,比如opacity, translate等等
     */
    var transforms = {
      "data-lax-opacity": function dataLaxOpacity(style, v) {
        style.opacity = v;
      },
      ...
    }


    // crazy类型的运动模式
    var _crazy = "";

    for (var i = 0; i < 100; i++) {
      _crazy += " " + window.innerHeight * (i / 100) + " " + Math.random() * 360 + ", ";
    }


    /**
     * lax所定义的一些运动模式
     * 不同的运动模式应用不同的transforms
     * lax.presets的每个值都是一个函数
     * 自定义了一个基于基本规则的运动模式
     */
    lax.presets = {
      linger: function linger() {
        return {
          "data-lax-translate-y": "(vh*0.7) 0, 0 200, -500 0"
        };
      },
      ...
    }


    /**
     * 添加自定义的运动模式
     */
    lax.addPreset = function (name, o) {
      lax.presets[name] = o;
    };


    /**
     * 返回计算后的值
     */
    function intrp(table, v) {}


    /**
     * lax的初始化方法
     */
    lax.setup = function (o) {
      lax.populateElements();
    };


    /**
     * lax的取消节点的方法
     */
    lax.removeElement = function (el) {
      var i = this.elements.findIndex(function (o) {
        return o.el = el;
      });

      if (i > -1) {
        this.elements.splice(i, 1);
      }
    };


    /**
     * lax的添加节点的方法
     */
    lax.addElement = function (el) {}



    /**
     * lax收集所有应用lax的节点
     */
    lax.populateElements = function () {}


    /**
     * lax更新节点的方法
     */
    lax.update = function (y) {
      lastY = y;
      lax.elements.forEach(lax.updateElement);
    };

    return lax;
  }()



  /**
    * 判断环境,是导出lax还是将lax赋值于window上
   */
  if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') module.exports = lax;else window.lax = lax;
})()

以上便是 lax 整个库的源码结构,简单的函数我就直接写出来解释了,复杂的比较关键的函数让我们来一个一个来看。

populateElements(收集)

首先我们从初始化的函数入手,lax.setup中调用了populateElements函数,那么让我们来瞧一瞧populateElements主要做了什么。

lax.populateElements = function() {
  lax.elements = [];
  var selector = Object.keys(transforms)
    .map(function(t) {
      return "[".concat(t, "]");
    })
    .join(",");
  selector += ",[data-lax-preset]";
  document.querySelectorAll(selector).forEach(this.addElement);
};

首先设置 lax.elements 为空,遍历 transforms 的键并依次添加到 selector 上,所以最后 selector 最后会是一个带有各个 transforms 键的字符串,如:'[data-lax-opacity], ... ,[data-lax-preset]',获取到 selector 后,直接使用 document.querySelectorAll 来获取所有的节点并调用addElement函数。

所以这一步主要就是收集各个应用了 lax 规则的节点并调用 addElement 函数。

接下来我们来看看 addElement 函数做了什么。

addElement(初始化节点)

lax.addElement = function(el) {
  var o = {
    el: el,
    transforms: []
  };

  var presetNames =
    el.attributes["data-lax-preset"] && el.attributes["data-lax-preset"].value;

  if (presetNames) {
    ..
  }
};

首先创建一个一个对象 o,并将 el 赋值给 o.el,同时设置 transforms 为空数组,用来储存后来所应用的动画参数。

然后获取 el 上的data-lax-preset.value,如果 presetNames 存在的话,则应用 lax.preset 函数。

presetNames

if (presetNames) {
  presetNames.split(" ").forEach(p => {
    const bits = p.split("-");
    const fn = lax.presets[bits[0]];
    if (!fn) {
      console.error(`preset ${bits[0]} is not defined`);
    } else {
      const d = fn(bits[1]);
      for (var k in d) {
        el.setAttribute(k, d[k]);
      }
    }
  });

  el.setAttribute("data-lax-anchor", "self");
  el.attributes.removeNamedItem("data-lax-preset");
}

首先一个节点上是可以应用几种运动模式的,而且是用空格分隔的,所以先用 split 获取到所有的运动模式,然后再对每个运动模式单独处理。

我们可以先看看 lax.preset 中定义的一些函数,就可以发现有很多函数是可以传值的,那么我们在定义的时候怎么将值传给 lax 的呢?没错,就是用-来分隔。

所以我们对获取到的每个运动模式使用p.split("-")来获取运动模式的名称和参数。如果 fn 不存在则报错,不然就执行fn(bits[1]),然后将返回的结果的所有键值赋给 el 节点上。

完成赋值后,el 添加data-lax-anchor为 self 的 data-set 属性,然后删去data-lax-preset。关于data-lax-anchor这个属性我们马上会介绍,主要就是设置运动过程中的参照物。

optimize

接下来我们来看后面的代码。

const optimise = !(
  el.attributes["data-lax-optimize"] &&
  el.attributes["data-lax-optimize"].value === "false"
);
if (optimise) el.style["-webkit-backface-visibility"] = "hidden";
if (el.attributes["data-lax-optimize"])
  el.attributes.removeNamedItem("data-lax-optimize");

获取data-lax-optimize属性,如果 value 为 false,则不使用-webkit-backface-visibility,否则则使用。然后删去data-lax-optimize属性。

这里介绍下webkit-backface-visibility这个 css 属性,它是指定当元素背面朝向观察者时是否可见。元素的背面总是透明的,当其朝向观察者时,显示正面的镜像。这样就比较符合生活,毕竟我们不可能在一个物体转了 180 度后还能看到它的正面对不对。

设置除上面 2 个参数之外的其他参数

for (var i = 0; i < el.attributes.length; i++) {
  var a = el.attributes[i];
  var bits = a.name.split("-");
  if (bits[1] === "lax") {
    if (a.name === "data-lax-anchor") {
      o["data-lax-anchor"] =
        a.value === "self" ? el : document.querySelector(a.value);
      const rect = o["data-lax-anchor"].getBoundingClientRect();
      o["data-lax-anchor-top"] = Math.floor(rect.top) + window.scrollY;
    } else {
      o.transforms[a.name] = a.value
        .replace(new RegExp("vw", "g"), window.innerWidth)
        .replace(new RegExp("vh", "g"), window.innerHeight)
        .replace(new RegExp("elh", "g"), el.clientHeight)
        .replace(new RegExp("elw", "g"), el.clientWidth)
        .replace(new RegExp("-vw", "g"), -window.innerWidth)
        .replace(new RegExp("-vh", "g"), -window.innerHeight)
        .replace(new RegExp("-elh", "g"), -el.clientHeight)
        .replace(new RegExp("-elw", "g"), -el.clientWidth)
        .replace(/\s+/g, " ")
        .split(",")
        .map(x => {
          return x
            .trim()
            .split(" ")
            .map(y => {
              if (y[0] === "(") return eval(y);
              else return parseFloat(y);
            });
        })
        .sort((a, b) => {
          return a[0] - b[0];
        });
    }
  }
}

lax.elements.push(o);
lax.updateElement(o);

因为上面 2 个参数在设置之后都会被 remove,所以这里也就不会被遍历到了。

同样的,我们这边先执行a.name.split("-"),毕竟我们只设置 lax 的参数,对于其他 data-set,我们是不会动的。

if (a.name === "data-lax-anchor") {
  o["data-lax-anchor"] =
    a.value === "self" ? el : document.querySelector(a.value);
  const rect = o["data-lax-anchor"].getBoundingClientRect();
  o["data-lax-anchor-top"] = Math.floor(rect.top) + window.scrollY;
} else {
  ...
}

如果 a.name 是 data-lax-anchor 的话,我们会判断 a.value 是不是 self,从而获取元素 o 的参照物是谁。

而这里参照物的意思就是元素(参照物)的 scrollY,这个具体会在 update 的时候介绍到。因为各个元素的坐标都会在页面滚动的时候变化,所以坐标变化的关键指标就是页面的 scrollY。也就是说没有设置参照物的时候,元素的坐标,transform 都会根据 window.scrollY 变化,不过如果设置了参照物,那么就是根据参照物相对于屏幕顶部的举例来变化(这里的距离屏幕顶部的距离指的是元素相对屏幕顶部的距离)。

我们上面讲过,设置了 lax.preset 的 anchor 都是 self,也就是说参照物都是自己。除了 lax.preset,我们也可以自己在元素上设置data-lax-anchor,比如#id, .class等等。

所以这段代码就是根据参照物计算出参照物距离屏幕顶部的距离并赋值给o["data-lax-anchor-top"]

那么在 else 的分支中的代码就是怎么处理不是data-lax-anchor的属性。

o.transforms[a.name] = a.value
  .replace(new RegExp("vw", "g"), window.innerWidth)
  .replace(new RegExp("vh", "g"), window.innerHeight)
  .replace(new RegExp("elh", "g"), el.clientHeight)
  .replace(new RegExp("elw", "g"), el.clientWidth)
  .replace(new RegExp("-vw", "g"), -window.innerWidth)
  .replace(new RegExp("-vh", "g"), -window.innerHeight)
  .replace(new RegExp("-elh", "g"), -el.clientHeight)
  .replace(new RegExp("-elw", "g"), -el.clientWidth)
  .replace(/\s+/g, " ")
  .split(",")
  .map(x => {
    return x
      .trim()
      .split(" ")
      .map(y => {
        if (y[0] === "(") return eval(y);
        else return parseFloat(y);
      });
  })
  .sort((a, b) => {
    return a[0] - b[0];
  });

这一段代码也比较简单,将属性的值利用正则依次替换里面的变量(vw, vh, elh…),然后将多个连续空格合并一个空格。

因为我们在设置动画属性的时候,肯定有一个初始状态,一个结束状态,而且可能会有 3 个 4 个或者更多的关键帧状态,那么这么多状态就是用逗号,来分隔的。

分隔完各个关键帧状态之后就用 map 依次来处理其中的数据(注意这里的每个数据都是’a b’这种类型的,前面的变量代表了参照物的相对距离,也就是上面讲到的 scrollY 或者 data-lax-anchor-top,后面的变量代表了这个属性 a 的 value)。

最后将获取到的值赋值给o.transforms[a.name]。所以当前元素的所有要变化的 css 值都被包含在了 o.transforms 中。

.map(x => {
    return x
      .trim()
      .split(" ")
      .map(y => {
        if (y[0] === "(") return eval(y);
        else return parseFloat(y);
      });
  })
  .sort((a, b) => {
    return a[0] - b[0];
  });

这段代码中,先去掉 x 的前后空格,然后根据空格分隔变量(因为前面我们已经通过正则将多个连续的空格合并成一个了),然后判断y[0]是不是”(“,如果是这个就说明里面是一个 js 运算公式,使用 eval 来执行,不然就是 parseFloat(变量)。

最后根据参照物的相对距离来排序,从小到大依次排列,毕竟在更新的时候肯定需要根据两个状态的 value 来计算当前帧的 css 属性值。

所以这里我们就可以看出,我们可以不需要按照 scrollY 从小到大来定义 data-lax 中的值,完全可以先写最底下的时候的状态,再写最上面时候的状态,最后再写中间的状态参数。

addElement 最后部分以及总结

lax.elements.push(o);
lax.updateElement(o);

很好理解,就是初始化节点之后依次添加到 lax.elements 中并调用 updateElement。

到这里 addElement 的部分就全部讲完了,让我们来总结一下流程。

  1. 首先将传入的参数 el 赋值给 o.el,并且查找 el 上是否存在 data-lax-preset 属性,如果存在则调用 lax.preset 的方法并设置相关运动模式的值给 el,最后删除 data-lax-preset 属性。

  2. 判断 el 上是否有 data-lax-optimize 属性,如果 value 不为 false 就说明要 optimize,就给el.style["-webkit-backface-visibility"]设置为 false,并且删除 data-lax-optimize 属性。

  3. 由于在第一步中可能存在 data-lax-preset,所以会自动给 el 添加部分 data-lax 属性 ,并且用户也可以自己添加属性,所以我们要处理这些属性。

  4. 如果属性名是data-lax-anchor,说明参照物要改成data-lax-anchor的值,获取参照物相对屏幕顶部的距离并将值赋给o["data-lax-anchor-top"]

  5. 如果属性名不是data-lax-anchor,则用正则对属性值依次替换部分变量,并且执行其中的表达式,对于变量则做 parseFloat 处理。最后按照关键帧的window.scrollY或者data-lax-anchor-top来从小到大排序。

Update

看过官方文档就了解,要使用 lax,在第一步 lax.setup 之后,只要监听 scroll 事件并执行 lax.update(即可)。

那么我们就来看看它的 update 函数是怎么写的。

lax.update = function(y) {
  lastY = y;
  lax.elements.forEach(lax.updateElement);
};

这段代码很简单,将 window.scrollY 赋值给 lastY 并且对于 lax.elements 中的每个元素执行 lax.updateElement 函数。

那么我们来看看它的 updateElement 函数。

lax.updateElement = function(o) {
  const y = lastY;
  var r = o["data-lax-anchor-top"] ? o["data-lax-anchor-top"] - y : y;

  var style = {
    transform: "",
    filter: ""
  };

  for (var i in o.transforms) {
    var arr = o.transforms[i];
    var t = transforms[i];
    var v = intrp(arr, r);

    if (!t) {
      console.error("lax: " + i + " is not supported");
      return;
    }

    t(style, v);
  }

  for (let k in style) {
    if (style.opacity === 0) {
      // if opacity 0 don't update
      o.el.style.opacity = 0;
    } else {
      o.el.style[k] = style[k];
    }
  }
};

var r = o["data-lax-anchor-top"] ? o["data-lax-anchor-top"]-y : y 就和我们之前讲到的一样,这里他会判断元素 o 上是否有data-lax-anchor-top属性,如果有,就会计算元素相对于屏幕顶部的距离(因为页面向下滚动了,那么这个参照物相对于屏幕顶部也就减少了那么多的滚动距离),如果没有则直接取 window.scrollY。

for (var i in o.transforms) {
  var arr = o.transforms[i];
  var t = transforms[i];
  var v = intrp(arr, r);

  if (!t) {
    console.error("lax: " + i + " is not supported");
    return;
  }

  t(style, v);
}

然后遍历 o.transforms 的属性并调用 intrp 函数,最后执行 t(style, v)。

光看这段代码,相比大家都可以猜出 intrp 函数的作用,因为 t 函数就是 transforms 中的函数,他就是将 value 赋值给 style 并返回。那么 intrp 必然就是根据 r 来计算最后的 value。

intrp

function intrp(t, v) {
  var i = 0;

  while (t[i][0] <= v && t[i + 1] !== undefined) {
    i += 1;
  }

  var x = t[i][0];
  var prevX = t[i - 1] === undefined ? x : t[i - 1][0];

  var y = t[i][1];
  var prevY = t[i - 1] === undefined ? y : t[i - 1][1];

  var xPoint = Math.min(Math.max((v - prevX) / (x - prevX), 0), 1);
  var yPoint = xPoint * (y - prevY) + prevY;

  return yPoint;
}

因为 arr 就是之前排完序的关键帧状态数组,根据当前的 v(当前的参照物相对坐标),我们来算出当前的 v 是处在所有关键帧中的哪里,可能是两个关键帧中间,或者某一个关键帧中。

这里的运算就是通过两个关键帧来计算,这里主要考虑了一个边界问题,就是往上滚动太长距离,元素被隐藏了,所以t[i-1]可能不存在,因为 t=0,所以就只有一个关键帧了。

如果现在你还不理解,我觉得可能是作者这个变量定义的不好。我们都知道 arr 的元素也是一个数组,而且这个数组的第一个值是参照物的相对位置,第二个值才是真正应用于 css 的 value。所以作者第一个变量定义为 x,第二个定义为 y 很明显就很容易误导人了,以为是 el 的 x,y。

其实你只要把 x 想成 state,y 想成 value 就容易想通了。

update 是否要更新呢

我们都知道,以前电脑性能不高的时候,监听 scroll 事件还会做节流与防抖。那么我们在计算更新元素坐标的时候,我们是不是也要优化部分不在屏幕中的元素呢?

在这个库中,下面的代码就是做了类似的事情。

for (let k in style) {
  if (style.opacity === 0) {
    // if opacity 0 don't update
    o.el.style.opacity = 0;
  } else {
    o.el.style[k] = style[k];
  }
}

但是这里我们又有个问题了,这个 opacity 也是我们自己设置的,如果我们不设置这个 opacity,那么不就无法优化了嘛。

很明显这个问题是存在的,今天写到这里的时候我又去看了下 github 上的源码,发现作者做了部分更新,将现在的 optimize 部分转移到了data-lax-use-gpu,然后重写了 optimize 逻辑。

const useGpu = !(
  el.attributes["data-lax-use-gpu"] &&
  el.attributes["data-lax-use-gpu"].value === "false"
);
if (useGpu) el.style["-webkit-backface-visibility"] = "hidden";
if (el.attributes["data-lax-use-gpu"])
  el.attributes.removeNamedItem("data-lax-use-gpu");

o.optimise = false;
if (
  el.attributes["data-lax-optimize"] &&
  el.attributes["data-lax-optimize"].value === "true"
) {
  o.optimise = true;
  const bounds = el.getBoundingClientRect();
  el.setAttribute(
    "data-lax-opacity",
    `${-bounds.height - 1} 0, ${-bounds.height} 1, ${
      window.innerHeight
    } 1, ${window.innerHeight + 1} 0`
  );
  el.attributes.removeNamedItem("data-lax-optimize");
}

经常我上面的分析,其实这部分代码已经是很好理解了把,我们就主要看看 optimize 的代码,如果我们要优化的化,lax 就会自动添加data-lax-opacity属性。

那么我觉得吧,这个属性还是需要自己设置data-lax-optimize,还不如默认全部开启data-lax-optimize,然后设置一个属性来关闭部分节点的 optimize 来的更实在呢。你说对不对呢~

总结

历时半天写了这篇博客,虽然这个库比较简单,但是感觉自己还是收获到了不少。

document.querySelectorAll(‘[], [], []’)的使用,webkit-backface-visibility 属性的了解以及部分 preset 函数的定义。

在这里先勉励一下自己,希望后面写 zrender 源码解析能够顺利(为什么我的 pr 还不回复我捏)~

blog comments powered by Disqus
目 录