Giter Site home page Giter Site logo

blog's People

Contributors

haochuan9421 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

blog's Issues

[译] Immer 下的不可突变数据和 React 的 setState

Immer 下的不可突变数据和 React 的 setState

Immer 是为 JavaScript 不可突变性打造的一个非常棒的全新库。之前像 Immutable.js 这样的库,它需要引入操作你数据的所有新方法。

它很不错,但是需要复杂的适配器并在 JSON 和 不可突变 之间来回转换,以便在需要时与其他库一起使用。

Immer 简化了这一点,你可以像往常一样使用数据和 JavaScript 对象。这意味着当你需要考虑性能并且想知道数据何时发生了变更,你可以使用三个等号来做严格的全等检查以及证明数据的确发生了变更。

你对 shouldComponentUpdate 的调用不再需要使用双等或者全等去遍历整个数据并进行比较。

文章截图

注:此处为截图,原文为视频,建议看英文原文。

对象展开运算符

在最新版本的 JavaScript 中,许多开发者依赖对象展开运算符来实现不可突变性。例如,你可以展开之前的对象并覆盖特定的属性,或者增加新的属性。它会在底层使用 Object.assign 并返回一个新对象。


const prevObject = {
  id: "12345",
  name: "Jason",
};

const newObject = {
  ...prevObject,
  name: "Jason Brown",
};

我们的 newObject 现在会是一个完全不同的对象,所以任何全等判断(prevObject === newObject)将会返回 false。所以它完全创建了一个新对象。name 属性也不再是 Jason 而是会变成 Jason Brown,而且由于我们没有对 id 属性进行任何操作,所以它会保持不变。

这也适用于 React,因为 React 只会合并最外层的属性,所以当你在 state 中有嵌套的对象时,你需要对之前的对象进行展开操作和更新。

让我们看一个例子。可以看到我们有两个嵌套的计数器,但是我们只想更新其中的一个而不影响另一个。

import React, { Component } from "react";

class App extends Component {
  state = {
    count: {
      counter: 0,
      otherCounter: 5,
    },
  };

  render() {
    return <div className="App">{this.state.count.counter}</div>;
  }
}

export default App;

下一步在 componentDidMount 钩子中,我们将设置一个间隔定时器来更新我们嵌套的计数器。不过,我们希望保持 otherCounter 的值不变。所以,我们需要使用对象展开运算符来把它从以前嵌套的 state 中带过来。

componentDidMount() {
    setInterval(() => {
      this.setState(state => {
        return {
          count: {
            ...state.count,
            counter: state.count.counter + 1,
          },
        };
      });
    }, 1000);
  }

这在 React 中是一个非常常见的场景。而且,如果你的数据是嵌套的非常深的,当你需要展开多个层级时,它会增加复杂性。

Immer Produce 基础

Immer 仍然允许使用突变(直接改变值)而完全无需担心如何去管理展开的层级,或者哪些数据我们触及过以及需要维持不可突变性。

让我们设置一个场景:你向计数器传递一个值来进行递增,与此同时,我们还有一个 user 对象是不需要被触及的。

这里我们渲染我们的应用并传递增量值。

ReactDOM.render(<App increaseCount={5} />, document.getElementById("root"));
import React, { Component } from "react";

class App extends Component {
  state = {
    count: {
      counter: 0,
    },
    user: {
      name: "Jason Brown",
    },
  };

  componentDidMount() {
    setInterval(() => {}, 1000);
  }

  render() {
    return <div className="App">{this.state.count.counter}</div>;
  }
}

export default App;

我们像之前那样设置了我们的应用,现在我们有一个 user 对象和一个嵌套的计数器。

我们将导入 immer 并把它的默认值赋给 produce 变量。在给定当前 state 时,它将帮助我们创建下一个 state。

import produce from "immer";

接下来,我们将创建一个叫做 counter 的函数,它接收 state 和 props 作为参数,这样我们就可以读取当前的计数,并基于 increaseCount 属性更新我们的下一次计数。

const counter = (state, props) => {};

Immer 的 produce 方法接收 state 作为第一个参数,以及一个为下一个状态改变数据的函数作为第二个参数。

produce(state, draft => {
  draft.count.counter += props.increaseCount;
});

如果你现在把他们放在一起。我们就可以创建计数器函数,它接收 state 和 props 并调用 produce 函数。然后我们按照对下一次状态期望的样子去改变 draft。Immer 的 produce 函数将为我们创建一个新的不可突变状态。

const counter = (state, props) => {
  return produce(state, draft => {
    draft.count.counter += props.increaseCount;
  });
};

我们更新后的间隔计数器函数大概会是这样。

componentDidMount() {
    setInterval(() => {
      const nextState = counter(this.state, this.props);
      this.setState(nextState);
    }, 1000);
  }

不过我们只是触及过 countcounter,我们的 user 对象上又发生了什么呢?对象的引用是否也发生了变化?答案是否定的。Immer 确切的知道哪些数据是被触及过的。所以,如果我们在组件更新之后进行一次全等检测,我们可以看到 state 中之前的 user 对象和之后的 user 对象是完全相同的。

componentDidUpdate(prevProps, prevState) {
    console.log(this.state.user === prevState.user); // Logs true
  }

当你考虑性能而使用 shouldComponentUpdate 时,或者类似于 React Native 中FlatList 那样,需要一种简单的方式来知道某一行是否已经更新时,这就非常的重要。

Immer 柯里化

Immer 可以使得操作更加简单。如果它发现你传递的第一个参数是一个函数而不是一个对象,它就会为你创建一个柯里化的函数。因此,produce 函数返回另一个函数而不是一个新对象。

当它被调用时,它会把第一个参数用作你希望改变的 state,然后还会传递任何其他参数。

因此,它不仅仅是可以创建一个计数器函数的(工厂)函数,就连 props 也会被代理。

const counter = produce((draft, props) => {
  draft.count.counter += props.increaseCount;
});

得益于 produce 返回一个函数,我们可以直接把它传递给 setState,因为 setState 有接收函数作为参数的能力。当你正在引用之前的状态时,你应该使用函数化的 setState(函数作为第一个参数)。在我们的场景中,我们需要引用之前的计数来把它增加到新的计数。它将传递当前的 state 和 props 作为参数,这也正是设置我们的 counter 函数所需要的。

所以我们的间隔计数器仅需要 this.setState 接收 counter 函数即可。

componentDidMount() {
    setInterval(() => {
      this.setState(counter);
    }, 1000);
  }

总结

这显然是一个人为的示例,但具有广泛的现实应用。可以轻松比较仅更新了单个字段的一长串列表数据。大型嵌套表单只需要更新触及过的特定部分。

你不再需要做浅比对或者深比对,而且你现在可以做全等检查来准确的知道你的数据是否发生了变化,而后决定是否需要重新渲染。


Originally published at Code.

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

用 Nuxt + Webhooks + Docker 撸一套自动化部署的 Vue SSR 项目

前言

一个前端项目简单的开发部署流程通常是这样的:先本地开发,当完成某个功能后构建打包,然后将生成的静态文件通过 ftp 或者其他方式上传到服务器中。并将代码 pushGitHub 或者 码云 等远端仓库中进行托管(为了突出本文的重点,暂不考虑测试的环节)。这种工作流不免有些劳神费力,而且每天频繁的打包上传也会占用很多时间。

一种理想的方式是:你只需要在服务器上创建一个“脚本”,执行这个脚本,他就会自动从 git 服务器拉取你的项目代码,并启动你的项目,而当你每次向 git 服务器 push 代码时,它又会自动拉取最新的代码并重新编译,更新服务。

为了实现上述的“理想方式”,本文将详细介绍如何使用 Nuxt + Webhooks + Docker 来实现一个 Vue SSR 项目的自动化部署。但我们首先需要解决这么几个问题:

  1. 如果在服务器上安全的拉取私有仓库的代码?
  2. 如果以生产环境(production)启动你的项目?
  3. 如果“通知”服务器你的代码已经更新了?
  4. 如果在不停止服务的前提下自动重新构建项目,自动更新?

要解决上面的问题,你需要了解以下基础知识:

  1. SSH Key
  2. 基本的 Nuxt + Docker 知识。
  3. 了解 Webhooks
  4. 基本的 Node + express 知识。

如果你对上述知识不是很了解或者不知道如何将他们结合在一起来以达到所谓的“理想方式”,那么接下的内容将从项目创建到实际部署,一步步的带你完成这项工作。

一、使用 create-nuxt-app 脚手架创建项目

创建时的各种选项如下图所示,你可以根据自己项目的实际情况进行选择,但 server framework 请选择 express,本文也将以 express 作为服务端框架展开介绍。

二、修改 package.json 中的 npm scripts

Nuxt 脚手架生成的项目,默认在生产环境下需要先执行 npm run build 构建代码,然后再执行 npm start 启动服务,这略显繁琐,也不利于自动部署、重新构建等工作的展开,这里将两者的功能合二为一,执行 npm start,即可在编码中使用构建并启动服务。得益于 Nuxt 配置中的 dev 参数, 在不同的环境下(NODE_ENV),即使使用的都是 new Builder(nuxt).build() 来进行构建,但由于 dev 参数的不同,Nuxt 的构建行为也会相应的不同并进行针对性的优化。这里生产环境(production)下启动服务也不再是通过 node 命令而是使用 nodemon,它用于监听 server/index.js 文件的变化,在 server/index.js 更新时可以自动重启服务。调整前后的 npm scripts 如下:

// 前
"scripts": {
  "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
  "build": "nuxt build",
  "start": "cross-env NODE_ENV=production node server/index.js"
}
// 后
"scripts": {
  "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
  "start": "cross-env NODE_ENV=production nodemon server/index.js --watch server"
}

同时,删除 server/index.js 中原本的条件判断:

//if (config.dev) {
  const builder = new Builder(nuxt);
  await builder.build();
//}

调整之后,执行 npm run dev,就会在 3000 端口启动一个有代码热替换(HMR)等功能的一个开发(development)服务,而执行 npm start 就会构建出压缩后的代码,并启动一个带 gzip 压缩等功能的生产(production)服务。

三、添加 Webhooks 接口

Webhooks 是什么?简单来说:假如你向一个仓库添加了 Webhook ,那么当你 push 代码时,git 服务器就会自动向你指定的地址,发送一个带有更新信息(payload)的 post 请求。了解更多,请阅读 GitHub 关于 Webhooks 的介绍文档 或者 码云的文档。由于我们使用了 express 来创建 http 服务,所以我们可以像这样方便的添加一个接口,用于接收来自 git 服务器的 post 请求:

...
// 订阅来自 git 服务器 的 Webhooks 请求(post 类型)
app.post('/webhooks', function(req, res) {
  // 使用 secret token 对该 API 的调用进行鉴权, 详细文档: https://developer.github.com/webhooks/securing/
  const SECRET_TOKEN = 'b65c19b95906e027c5d8';
  // 计算签名
  const signature = `sha1=${crypto
    .createHmac('sha1', SECRET_TOKEN)
    .update(JSON.stringify(req.body))
    .digest('hex')}`;
  // 验证签名和 Webhooks 请求中的签名是否一致
  const isValid = signature === req.headers['x-hub-signature'];
  // 如果验证通过,返回成功状态并更新服务
  if (isValid) {
    res.status(200).end('Authorized');
    upgrade();
  } else {
    // 鉴权失败,返回无权限提示
    res.status(403).send('Permission Denied');
  }
});
...

这里的 app 是一个 express 应用,我们通过了 Nodecrypto 模块计算签名并和 Webhooks 请求中的签名比对来进行鉴权,以保证接口调用的安全性(这里的能够获取到 Webhooks 请求的请求体 —— req.body 是由于使用了 body-parser 中间件)。如果鉴权通过则返回成功状态,并执行 upgrade 函数来更新服务,如果鉴权失败,则返回无权限提示。同时,你需要向仓库添加 Webhook,如下图:

四、如何无缝更新服务

如果你的项目已经在 http://www.example.com/ 下启动成功,那么当你每次向 GitHub 仓库 push 代码时,你的接口都会收到一个来自 GitHubpost 请求,并在鉴权通过后执行 upgrade 函数来更新服务。关于如何在服务器上启动项目我们按下不表,先介绍 upgrade 函数都做了什么。

/**
 * 从 git 服务器拉取最新代码,更新 npm 依赖,并重新构建项目
 */
function upgrade() {
  execCommand('git pull -f && npm install', true);
}

execCommand 函数如下,这里我们使用了 Nodechild_process 模块,用以创建子进程,来执行拉取代码, 更新 npm 依赖等命令:

const { execSync } = require('child_process');
/**
 * 创建子进程,执行命令
 * @param {String} command 需要执行的命令
 * @param {Boolean} reBuild 是否重新构建应用
 * @param {Function} callback 执行命令后的回调
 */
function execCommand(command, reBuild, callback) {
  command && execSync(command, { stdio: [0, 1, 2] }, callback);
  // 根据配置文件,重新构建项目
  reBuild && build();
}

build 函数,会根据配置文件,重新构建项目,这里的 upgrading 是一个标记应用是否正在升级的 flag

/**
 * 根据配置,构建项目
 */
async function build() {
  if (upgrading) {
    return;
  }
  upgrading = true;
  // 导入 Nuxt.js 参数
  let config = require('../nuxt.config.js');
  // 根据环境变量 NODE_ENV,设置 config.dev 的值
  config.dev = !(process.env.NODE_ENV === 'production');
  // 初始化 Nuxt.js
  const nuxt = new Nuxt(config);
  // 构建应用,得益于环境变量 NODE_ENV,在开发环境和生产环境下这个构建的表现会不同
  const builder = new Builder(nuxt);
  // 等待构建
  await builder.build();
  // 构建完成后,更新 render 中间件
  render = nuxt.render;
  // 将 flag 置反
  upgrading = false;
  // 如果是初次构建,则创建 http server
  server || createServer();
}

createServer 函数如下,这里有两个全局变量,renderserver,其中 render 变量保存了最新构建后的 nuxt.render 中间件,而 server 变量是应用的 http server 实例。

/**
 * 创建应用的 http server
 */
function createServer() {
  // 向 express 应用添加 nuxt 中间件,重新构建之后,中间件会发生变化
  // 这种处理方式的好处就在于 express 使用的总是最新的 nuxt.render
  app.use(function() {
    render.apply(this, arguments);
  });
  // 启动服务
  server = app.listen(port, function(error) {
    if (error) {
      return;
    }
    consola.ready({
      message: `Server listening on http://localhost:${port}`,
      badge: true
    });
  });
}

访问这里,查看完整的 server/index.js 文件。但这里存在一个问题☝️,就是每次执行 build 函数,重新构建时,由于 Nuxt 会删除上一次构建生成的文件(清空.nuxt/dist/client.nuxt/dist/server 文件夹),而构建完成之后才会生成新的文件,那么如果用户恰好在这个空档期访问网站怎么办?一种解决方案是干预 webpack 的这种行为,不去清空这两个文件夹,不过我目前没有找到 Nuxt 中可以修改这个配置的地方(欢迎评论),另一种解决方案就是在项目重新构建的时候,给用户返回一个友好的提示页,告诉他系统正在升级中。这也是我设置 upgrading 变量来标记应用是否正在升级中的意义所在,下面这段代码将展示,如果实现这种效果:

const express = require('express');
const app = express();
// 拦截所以 get 请求,如果系统正在升级中,则返回提示页面
app.get('*', function(req, res, next) {
  if (upgrading) {
    res.sendFile('./upgrading.html', { root: __dirname });
  } else {
    next();
  }
});

要说明的一点是:app.get('*', ...) 必须写在前面,你可以在这里Description 中找到解释。如此一来,当用户恰好在应用重新构建时访问网站,就会出现一个友好的提示页,而当构建完成后,用户再次访问网站,就是一个升级后的应用,整个过程,服务器始终是保持在线的状态,http server 并没有停止或者重启。

至此,你已经可以把项目代码上传到 GitHub 或者 码云了(不同的服务商对 Webhooks 的鉴权方式可能会有所不同,你需要参考他们的文档对接口的鉴权方式进行一点调整)。

五、部署公钥管理

为私有项目添加部署公钥,使得项目在服务器上或者在 Docker 中可以安全的进行代码克隆和后续的拉取更新,参考链接1参考链接2。这里以 GitHub 为例进行介绍:

  1. 生成一个 GitHub 用的 SSH key

    ssh-keygen -t rsa -C '[email protected]' -f ~/.ssh/github_id_rsa

    一般情况下,是不需要使用 -f ~/.ssh/github_id_rsa 来指定生成 SSH Key 的文件名的,默认生成的是 id_rsa。但考虑到一台机器同时使用不同的 git 服务器的可能性,所以这里对生成的 SSH key 名称进行了自定义。这里的邮箱是你的 git 服务器 (GitHub)登录邮箱。

  2. ~/.ssh 目录下新建一个 config 文件,添加如下内容,参考文档

    # github
    Host github.com
    HostName github.com
    StrictHostKeyChecking no
    PreferredAuthentications publickey
    IdentityFile ~/.ssh/github_id_rsa

    其中 HostHostName 填写 git 服务器的域名,IdentityFile 指定私钥的路径,StrictHostKeyChecking 设置为 no 可以跳过下图中 (yes/no) 的询问,这一点对于 Docker 流畅的创建镜像很有必要(否则可能要写 expect 脚本),当然你也可以通过执行 ssh-keyscan github.com > ~/.ssh/known_hostshost keys 提前添加到 known_hosts 文件中。

  3. 在项目仓库添加部署公钥

  4. 测试公钥是否可用

    如果出现下图所示内容则表明大功告成,可以执行下一步了。👏👏👏🎉🎉🎉

至此,如果你不需要使用 Docker 部署,而是使用传统的部署方式,那么你只需要在服务器上安装 Nodegit,并把仓库代码克隆到服务器上,然后执行 npm start 在 80 端口启动服务就可以了。你可以使用 nohup 命令或者 forever 等使服务常驻后台。

六、Docker 部署

1. 安装 Docker CE (阿里云 Ubuntu 18.04 已亲试)

2. 创建 Dockerfile

# 添加 node 镜像,:8 是指定 node 的版本,默认会拉取最新的
FROM node:8
# 定义 SSH 私钥变量
ARG ssh_prv_key
# 定义 SSH 公钥变量
ARG ssh_pub_key
# 在 /home 下创建名为 webhooks-nuxt-demo 的文件夹
RUN mkdir -p /home/webhooks-nuxt-demo
# 为 RUN, CMD 等命令指定工作区
WORKDIR /home/webhooks-nuxt-demo
# 创建 .ssh 目录
RUN mkdir -p /root/.ssh
# 生成 github_id_rsa、github_id_rsa.pub 和 config 文件
RUN echo "$ssh_prv_key" > /root/.ssh/github_id_rsa && \
    echo "$ssh_pub_key" > /root/.ssh/github_id_rsa.pub && \
    echo "Host github.com\nHostName github.com\nStrictHostKeyChecking no\nPreferredAuthentications publickey\nIdentityFile /root/.ssh/github_id_rsa" > /root/.ssh/config
# 修改私钥的用户权限
RUN chmod 600 /root/.ssh/github_id_rsa
# 克隆远端 git 仓库代码到工作区,注意最后的 . 不能省略
RUN git clone [email protected]:HaoChuan9421/webhooks-nuxt-demo.git .
# 安装依赖
RUN npm install
# 对外暴露 3000 端口
EXPOSE 3000
# 启动时的执行脚本
CMD npm start

3. 创建 Docker Image

通过 cat 命令读取之前创建的 SSH 公钥和私钥的内容并作为变量传递给 Docker。由于 build 镜像的过程需要执行 git clonenpm install,取决于机器性能和带宽,可能需要花费一定的时间。一个正常的 build 过程如下图:

docker build \
-t webhooks-nuxt-demo \
--build-arg ssh_prv_key="$(cat ~/.ssh/github_id_rsa)" \
--build-arg ssh_pub_key="$(cat ~/.ssh/github_id_rsa.pub)" \
.

4. 启动容器

在后台启动容器,并把容器内的 3000 端口 发布到主机的 80 端口。

sudo docker run -d -p 80:3000 webhooks-nuxt-demo

5. 进入执行中的容器

必要的时候可以进入容器中执行一些操作:

# 列出所有容器
docker container ls -a
# 进入指定的容器中
docker exec -i -t 容器名称或者容器ID bash

七、留个后门

有时候我们可能需要执行一些命令,来对项目进行更佳灵活的操作,比如切换项目的分支、进行版本回滚等。但如果只是为了执行一行命令就需要连接服务器,再进入容器内,难免有些繁琐,启发于 Webhooks,我们不妨留个后门👻:

// 预留一个接口,必要时可以通过调取这个接口,来执行命令。
// 如:通过发起下面这个 AJAX 请求,来进行 npm 包的升级并重新构建项目。
// var xhr = new XMLHttpRequest();
// xhr.open('post', '/command');
// xhr.setRequestHeader('access_token', 'b65c19b95906e027c5d8');
// xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
// xhr.send(
//   JSON.stringify({
//     command: 'npm update',
//     reBuild: true
//   })
// );
app.post('/command', function(req, res) {
  // 如果必要的话可以进行更严格的鉴权,这里只是一个示范
  if (req.headers['access_token'] === 'b65c19b95906e027c5d8') {
    // 执行命令,并返回命令的执行结果
    execCommand(req.body.command, req.body.reBuild, function(
      error,
      stdout,
      stderr
    ) {
      if (error) {
        res.status(500).send(error);
      } else {
        res.status(200).json({ stdout, stderr });
      }
    });
    // 如果是纯粹的重新构建,没有需要执行的命令,直接结束请求,不需要等待命令的执行结果
    if (!req.body.command && req.body.reBuild) {
      res.status(200).end('Authorized and rebuilding!');
    }
  } else {
    res.status(403).send('Permission Denied');
  }
});

八、总结

如果你按照上述步骤成功了部署了你的 Vue SSR 项目,那么当你每次 push 代码到 git 服务器,它都会自动拉取并更新。👏👏👏🎉🎉🎉

虽然我试图全面详细的介绍如何撸一套自动化部署的前端项目,但这对于一个真实的项目来说,可能远远不够。

例如,对于测试而言,可能我们需要创建两个的 Docker 镜像(或者使用两台服务器),一个启动在 80 端口,一个启动在 3000 端口,分别拉取 master 分支和 dev 分支的代码,通过对 Webhookspayload 进行判断,来决定这次的 push 行为应该更新哪个服务,通常我们在 dev 上进行频繁的提交,由测试人员测试通过之后,我们将 dev 分支的代码阶段性地合并到 master 分支,来进行正式版的更新。

又比如日志监控的完善等等,所以我的这篇博客权当抛砖迎玉,欢迎各位大佬指正不足之处,评论交流,或者给我的这个项目提交 PR,大家一起来完善这个事情。

[记] 深圳阿里中心举办的第一届前端艺术家沙龙

第一届前端艺术家沙龙,第一个找到组织的路人(哈哈,先给自己贴着标签)。当时我也是偶然发现的,就这样安静了大概有一个月,群里基本没什么动静,那时候只有这个网站——第一届前端艺术家沙龙可以看到一点相关信息。再后来@liuxuewei每天早上会在群里分享一些技术文章,一直坚持到现在,虽然自己也只是偶尔挑着看几篇,但还是很感谢学炜哥的坚持。到了9月8号,自己非常有幸,能亲身参加这次活动,看到了一众行走的大佬和阿里漂亮的前端小姐姐们。

沙龙一开场是两位从事信息无障碍工作的朋友给大家带来的音乐表演,之后@蔡勇斌还给大家介绍了“不为人知的前端开发”。其实作为普通的前端程序员,大家可能更多的关注的是页面布局,数据,逻辑等,很少有人去想象一个视障用户如何使用我们的产品。就比如最简单的img标签,视障用户更关注的是alt属性,又比如很常见的鼠标悬浮显示子菜单的交互,视障者更多依赖的是键盘而不是鼠标,他们可能根本无法完成这个操作,等等。。。虽然这些都不是什么难点,但这些确是“不为人知的前端”,也是我们常常忽略的,希望大家可以看一下蔡勇斌的这场Live:意外失明后,我怎么学会编程并跟 BAT 程序员一起工作?多关注他们,也从我做起,规范开发,让更多的人能够平等享受科技的乐趣。

第一位讲师是来自阿里妈妈的CSS专家@一丝。主要给大家介绍了CSS Houdini。下图是浏览器渲染页面的过程,从DOM/CSSOM 到最终在屏幕上显示,原本我们能干预的只是DOM + 部分CSS,而CSS Houdini可以给我们解锁更多姿势,提供更多API,甚至可以结合Canvas来玩,让一切皆有可能。

v2-fc570c29e10ec610dc0e5f649ecbbdde_hd.gif

Demo地址:https://github.com/yisibl/houdini-demo

第二位讲师是来自京东凹凸实验室的余澈,介绍了多端统一开发框架—— Taro。只要用了Tero,妈妈就再也不用担心我加班了,一套Taro代码可以构建多端应用(H5 + 小程序 + RN + 快应用 + ...)。不过目前更像是用React写微信小程序。而且Tero不仅要保持和React的同步,也要积极适配微信小程序等的更新,适配的越多,兼容性的坑就越多 。个人感觉还是需要和其他大厂合作。不过一套代码多端适配这个思路很好,而且现场还出现了不少Taro的使用者。期待Taro不断发展,解放一众苦难中的前端er。

第三位讲师是大B站的前端架构师赵淳煜,介绍了B站改版Vue之后、如何用一年的时间搭建从0到亿级规模的Node同构渲染,演讲方式是按开发时间递进,由问题引出解决方案,介绍了很多自己爬过的坑。讲解中涉及了:优化SEO、 多层降级、前端代码热更新、缓存与实时性、容灾兜底等等。可以说干货满满。讲的还是很好的,不过话说大B站啥时候发展到深圳呀?


第四位讲师是阿里巴巴供应链体验技术部的鸣波。介绍了更好的表单方案NoForm。目前是配合React用。和antd一起食用效果更佳。整体来看更像是表单的 Wrapper。可以给嵌套表单,手风琴表单等各种复杂的表单场景提供更可控、易扩展、可维护的开发体验,据说灵感来自阿里的史诗级表单(目测上百个字段)。NoForm使用也很友好、简单,详细的可以看GitHub:https://github.com/alibaba/noform 下一步可能会支持Vue等框架。

第五位讲师是UC国际业务部的@衍良。主要介绍前端的国际化。直到听了讲座我才知道常见的i18nInternationalization省略中间 18 个字母的缩写😂,而且令我吃惊的是UC浏览器在印度居然是仅次于Chrome 的存在,剩下的也就是听个热闹了,感觉自己就像一个前端届的小草履虫。

总体来说,这次沙龙还是了解到了很多新鲜的技术,也拓展的自己的视野,不枉此行。而且这次活动是免费的!!!去之前也和学炜哥建议过,当时觉得大家每人出了点钱也无关紧要的,甚至建议把盈余的钱捐给开源项目。但是学炜哥坚持认为前端艺术家应该打造成一个前端届的良心品牌:有干货、非商业、自由严谨。最后由于讲师干货太多,听众问题太多,一不小心超时了,准备的抽奖环节也变成了『没得到奖品的都上来拿吧』。

不管是讲师,还是客串的嘉宾主持,还是幕后默默付出的组织者,大家都为了第一届能成功举办费心费力,8号结束的晚上,为了尽快更新PPT,还在加点整理,@一丝Demo昨天在GitHub上还有Commit。没去的小伙伴也可以从第一届前端艺术家沙龙下载讲师的PPT,后续可能还会有视频放出。参加的小伙伴水平也都很不错,提了很多非常有水平的问题。据说这次有千人报名,最后只筛了120多人,明年会扩大场地,办一次千人规模的第二届,在深圳的前端er可以持续关注。

深入理解 Cookie 的 SameSite 属性

Cookie 简介

HTTP 协议是无状态的,但可以通过 Cookie 来维持客户端与服务端之间的“会话状态”。

简单来说就是:服务端通过 Set-Cookie 响应头设置 Cookie 到客户端,而客户端在下次向服务器发送请求时添加名为 Cookie 的请求头,以携带服务端之前“埋下”的内容,从而使得服务端可以识别客户端的身份。

举个简单的🌰:

// 服务端
const http = require("http");

http
  .createServer((req, res) => {
    if (req.url == "/") {
      res.end("hello world");
    } else if (req.url == "/favicon.ico") {
      res.statusCode = 204;
      res.end();
    } else {
      res.writeHead(200, [
        ["Set-Cookie", "name=haochuan9421"], // 设置 cookie
      ]);
      res.end("some data");
    }
  })
  .listen(80);
// 客户端
var xhr = new XMLHttpRequest();
xhr.open('GET', "/someapi");
xhr.send();

image

当客户端再次发起请求时就会自动携带上之前“埋下”的 Cookie:

image

简单的介绍完 Cookie 后,我们来看一下它的 SameSite 属性。

SameSite 属性

image

SameSite 有三个可选值:

  • Strict
  • Lax
  • None

从 Chrome 80 开始,如果不指定 SameSite 就等效于设置为 Lax。你可以通过 chrome://flags/#same-site-by-default-cookies 禁用这个行为,禁用后不指定 SameSite 就等效于设置为 None。关于他们的区别我们稍后结合具体的场景来介绍。

image

先来看看上图中出现的 third-party 这个概念,对 Cookie 来说什么是 第三方 呢?

举个例子:假设我们现在访问的网站是 'bar.com',当我们引入 'foo.com' 的图片时,图片服务如果设置了 Cookie,我们就称之为 “第三方 Cookie”。目前在新版的 Chrome 浏览器中,只有指定 Cookie 的 SameSite 属性为 None 且 Secure 属性为 true 才可以设置 “第三方 Cookie”(后面会具体介绍)。用户是可以在浏览器偏好设置中阻止“第三方 Cookie”的。

image.png

简单来说就是:在当前访问的网站请求服务的网站是“跨站”(Cross Site)的情况下,第三方服务设置的 Cookie 就称之为 “第三方 Cookie”

是否是 “跨站” 不是根据同源策略(协议,主机,端口)来判断,而是 PSL(公共后缀列表)。比如 'foo.example.com' 和 'bar.example.com' 就不属于 “跨站”,因为他们同属于 example.com,是“同站”。这里也不能简单理解为二级域名相同,比如 'foo.github.io' 和 'bar.github.io',虽然都是 'github.io' 的子域名,但是他们之间是跨站的,因为 'github.io' 是在 PSL 中的,相当于顶级域名,可以在此处查看哪些域名是属于 PSL 的。

这其实和 Cookie 的 Domain 属性设置是差不多的。我们都知道子域名是可以设置父域名 Cookie 的,比如 'foo.example.com' 的请求是可以设置 Domain 为 '.example.com' 的 Cookie 的。但是 'foo.github.io' 的请求是不可以设置 Domain 为 '.github.io' 的 Cookie 的。这就像你无法设置 Cookie 的 'Domain' 为 '.com' 一样。因为 '.com' 和 'github.io' 都在 PSL 中。

image.png

image.png

更权威的解释可以参考这里"Same-site" and "cross-site" Requests

端口不同时,比如我们的网站是 bar.com:8080 ,我们引入 bar.com:9000 的图片时不会判定为第三方的。

协议(Scheme)不同判定为第三方。比如我们的网站是 'http://bar.com' ,我们引入 'https://bar.com' 的图片时会判定为第三方。不过在 Chrome 中你可以通过 chrome://flags/#schemeful-same-site 来忽略协议的限制。

Cookie 本身是不区分端口和协议(Scheme)的。

image

除了加载第三方网站图片的场景,向第三方网站发起 AJAX/fetch 请求嵌入第三方网站的 iframe表单提交到第三方网站链接跳转到第三方网站等都可能涉及到“第三方 cookie”。针对这些可能出现 “第三方cookie” 的场景,SameSite 设置为不同的值又会有哪些不同的效果呢?让我们来一一探究(多图警告😀):

1. AJAX 请求

当我们跨域发送 AJAX 请求时,由于浏览器同源策略的限制,我们的请求是无法发送的:

image

不过我们可以使用 CORS 的方式来解决跨域的问题:

const http = require("http");

http
  .createServer((req, res) => {
    if (req.url == "/") {
      res.end("hello world");
    } else if (req.url == "/favicon.ico") {
      res.statusCode = 204;
      res.end();
    } else {
      res.writeHead(200, [
        ["Set-Cookie", "name=haochuan9421"], // 设置 cookie
        ["Access-Control-Allow-Origin", "*"], // 允许跨域请求
      ]);
      res.end("some data");
    }
  })
  .listen(80, "0.0.0.0");

image

但是当我们再次发起请求时,虽然这个跨域请求的响应头中有设置 Cookie,却发现下次请求时并不会携带之前服务器设置的 Cookie。

image

这就带来一个问题,我们失去了利用 Cookie 来维持服务端与客户端“会话状态”的能力。那么如何在向第三方网站请求的时候携带 Cookie 呢?需要满足如下条件:

  1. 网站开启 https 并将 Cookie 的 Secure 属性设置为 true
  2. Access-Control-Allow-Origin 设置为具体的 origin,而不是 *
  3. Access-Control-Allow-Credentials 设置为 true
  4. SameSite 属性设置为 None

想在本地测试这段代码的同学需要注意一下,www.foo.com 和 www.bar.com 的请求都会打到这个服务上,通过修改电脑的 hosts 文件很容易做到这一点,https 的证书是采用 mkcert 生成的自签名证书。

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        res.end("hellow world");
      } else if (req.url == "/favicon.ico") {
        res.statusCode = 204;
        res.end();
      } else {
        res.writeHead(200, [
          ["Set-Cookie", "name=haochuan9421; Secure; SameSite=None"],
          ...(req.headers.origin // 跨域请求时请求头中会包含 origin,也就是请求发出的网站
            ? [
                ["Access-Control-Allow-Origin", req.headers.origin], // 不可以使用 *,必须指定
                ["Access-Control-Allow-Credentials", "true"], // 设置允许跨域请求携带 Cookie
              ]
            : []),
        ]);
        res.end("some data");
      }
    }
  )
  .listen(443, "0.0.0.0");

满足上面的条件之后,跨域请求就可以携带 Cookie 了:

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('GET', "https://www.bar.com/someapi");
xhr.send();

image

这四个条件缺一不可:

当不开启 https 的时候:

image

当不设置 Secure 属性:

image

当 Access-Control-Allow-Origin 设置为 * 时

image

当 Access-Control-Allow-Credentials 的值不为 true 时

image

当 SameSite 属性设置为 Strict 或 Lax 时

image
image

对于使用浏览器的 fetch API 发送请求也是一样的,使用 fetch 发起跨域请求时如果想携带 cookie,需要设置 "credentials" 为 "include":

fetch("https://www.bar.com/somedata", {
  "method": "GET",
  "credentials": "include"
})

2. 嵌套第三方 iframe

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      console.log(req.headers.host);
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<div>这是父页面</div>
<iframe src="https://www.bar.com/"></iframe>`);
        } else {
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=None"],
            ["Content-Type", "text/html;charset=utf-8"],
          ]);
          res.end(`<div>这是子页面</div>`);
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

如果设置了SameSite 为 Strict:
image
如果设置了SameSite 为 Lax:
image
如果不指定 SameSite:
image
如果设置了 SameSite 为 None:
image

这说明只有明确的指定了 SameSite 为 None 时,跨域 iframe 页面被引入时 Cookie 才能生效。

举例说明一下:假设我们希望在自己的网站内嵌 bilibili 的视频播放器,直接通过 iframe 把 B 站播放器引入到我们自己的网站是无法使用 1080p 画质的。

<iframe
  src="//player.bilibili.com/player.html?bvid=BV1Vv41157uK&high_quality=1"
  allowfullscreen="allowfullscreen"
  width="100%"
  height="500"
  scrolling="no"
  frameborder="0"
></iframe>

image

这是由于 B 站 Cookie 的 SameSite 属性并没有设置为 None,内嵌在其他第三方网站时 B 站播放器无法传递 Cookie 到服务器,服务器也就拿不到用户的登录态,对于未登录的用户 B 站是不提供 1080p 播放的。

不过在 Chrome 中我们可以通过禁用 chrome://flags/#same-site-by-default-cookies 来让”第三方 cookie“默认为 None,当我们关闭这个选项并重启浏览器之后,就可以在内嵌 iframe 中播放 1080p 的 B站视频了(前提是在 B 站已经登录过)。
image

3. 加载第三方图片或脚本等

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      console.log(req.headers.host, req.url);
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<div>这是父页面</div>
<img src="https://www.bar.com/"></img>`);
        } else {
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=Strict"],
            ["Content-Type", "image/png"],
          ]);
          fs.createReadStream("logo.png").pipe(res);
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

image
image
image
image

这和引入第三方的 iframe 是一样的,只有 SameSite 属性为 None,Cookie 才能生效。

举个应用的例子:下图是一个添加了谷歌广告的网站,可以看到谷歌广告相关的 Cookie 会把 SameSite 属性设置为 None。这样当足够多的网站引入了谷歌的广告脚本等资源时,他就可以构建出用户在各个网站的浏览轨迹以及访问偏好了,从而精准的推送广告。

image

4. 提交表单到第三方网站

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<form action="https://www.bar.com/" method="post" enctype="multipart/form-data">
<input type="text" name="name" />
<input type="number" name="age" />
<button type="submit">提交</button>
</form>`);
        } else {
          console.log(req.headers.host, req.url, req.method, req.headers.cookie);
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=Strict"],
          ]);
          res.end("ok");
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

image
image
image

从上面的测试中可以看出将 SameSite 设置为 None 是一种危险的行为,它会使得针对你的网站发起 CSRF (Cross-site request forgery) 攻击变得非常容易,因为从一个第三方恶意网站向你的网站发起的请求也会携带 Cookie,这使得伪造的请求会被识别为一次普通用户发起的请求。下面具体演示一下,我们假设 www.foo.com 是一个恶意网站,www.bar.com 是我们自己的网站:

这部分的示例只是为了说明问题,只展示一些关键步骤,具体的细节,比如登录和登陆态校验的实现会被简化

// 这是我们自己正常的网站
const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        // 我们网站首页有一个转账的表单
        res.setHeader("Content-Type", "text/html;charset=utf-8");
        res.end(`<form action="/transfer" method="post">
<input type="number" name="money" />
<button type="submit">提交</button>
</form>`);
      } else if (req.url == "/login") {
        // 登录后,客户端会存储用户的 Cookie 信息
        res.setHeader("Set-Cookie", "name=haochuan9421; Secure; SameSite=None");
        res.end("login success");
      } else if (req.url == "/transfer") {
        // 登录后的用户可以转账,未登录的不能转账
        res.end(req.headers.cookie ? "ok" : "fail");
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

用户直接访问 www.bar.com 提交表单转账,由于没有登录(没有 Cookie)会提示失败,所以用户会先进入 www.bar.com/login 登录,登录后客户端会有 Cookie,当用户回到首页再次提交转账表单时,就会转账成功,这模拟了一个简单的基于 Cookie 鉴权的网站。

接下来我们一起来看看攻击者是如何突破 www.bar.com 的鉴权滴。当攻击者知道了你网站有转账的功能,那么他就可以诱导用户进入准备好的恶意网站,在这个恶意网站中向你的网站发起转账请求,如果进入恶意网站的用户之前登录过你的网站并且登录态没有过期,那么这次伪造的请求就会成功把用户的钱转走。下面是恶意网站的代码:

// 这是一个要伪造请求的恶意网站
const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        res.setHeader("Content-Type", "text/html;charset=utf-8");
        res.end(`<div>这是一个恶意网站</div>
<form
id="fake-form"
action="https://www.bar.com/transfer"
method="post"
target="submit-target"
>
    <input type="hidden" name="money" value="1000" />
</form>
<iframe name="submit-target"></iframe>
<script>document.getElementById("fake-form").submit();</script>`);
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

image

可以看到,用户被诱导进入恶意网站后,恶意网站自动像你的服务器发起了伪造的转账请求,由于你 Cookie 中的 SameSite 属性设置为 None,这就导致这次伪造的请求也会携带用户的 Cookie,单纯基于 Cookie 做的接口鉴权就被攻破了,用户的资金面临安全风险。这也是为什么最新版的浏览器都会把 SameSite 的默认值从 None 调整为 Lax 的一个重要原因。

5. 链接跳转第三方网站

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<div>foo page</div>
<a href="https://www.bar.com/">www.bar.com</a>`);
        } else {
          console.log(req.headers.host, req.url, req.headers.cookie);
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=None"],
            ["Content-Type", "text/html;charset=utf-8"],
          ]);
          res.end("bar page");
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

image
image
image

Strict 这个规则过于严格,可能造成非常不好的用户体验。比如,当前网页有一个 GitHub 链接,用户点击跳转就不会带有 GitHub 的 Cookie,跳转过去总是未登陆状态。

总结

现代浏览器针对 Cookie 的 SameSite 属性的默认值已经很合理了,作为网站所有者通常不需要手动设置这个属性,一般只有当我们的服务需要和“第三方”对接时才考虑怎么设置更合理。

Strict 最为严格,表示完全禁止“第三方 Cookie”,只有当前网页的 URL 与请求目标一致时,才会带上 Cookie,一般用于保证系统的封闭性和安全性。

Lax 是目前大多数现代浏览器的默认值,他在保证安全性的前提下,也可以避免一些不好的用户体验,比如从别的网站跳转过时会没有登录态。

None 是最为宽松的一种设定,通常用于开放我们的服务给不同的第三方接入,同时又需要追踪用户的场景,比如广告,设置为 None 时需要考虑开放的安全性。

如何开发一款 60fps 的“无缝滚动”插件

什么是“无缝滚动”

所谓的“无缝滚动”就是多屏切换的过程是连续可循环的,而不是到最后一屏就停止播放。这种业务场景在实际开发中很常见,下面是“淘宝”和“京东” H5 版的首页截图,里面的 “banner 图”以及“头条栏”就是典型的无缝滚动的场景。但是体验一番之后,你会发现他们和原生 App 中的效果还是有一定差距的。你可以扫码打开在自己手机上体验一下,然后再打开他们的 App 划一划试一试,你会发现 H5 版本的似乎少了点什么

  

淘宝:  京东:

你可能发现了!H5 版的似乎少了对用户手势意图的判断:比如下图中的场景,如果在淘宝 H5 版的 banner 上你慢慢的左右晃动,它只会简单的比较 touchstarttouchend 事件触发时的横坐标来决定向哪个方向前进一屏。而如果在原生 App 上这么做,在结束时,他会回弹到占据当前屏幕大部分面积的那一屏,也就是第一屏,而当你是用手指快速扫过时,同样的位置,他则会切换到第二屏。

相比之下京东的 H5 体验会稍差一些,你在滑动的过程中他根本就“不跟手”,只是当你停止后,才判定方向(本文写于 2019 年 3 月,随着网站的升级,体验可能会有所不同)。

作为标杆型的大厂,自己同样的产品在 App 端 和 H5 端表现的差异性,他们自己肯定是知道的,但是为什么没有做到一致呢?想来用前端的技术去实现这个应该是需要一点额外的开发成本的或者存在卡顿等体验问题的。让我们来大致分析一下他们目前是通过什么方式实现“无缝滚动”的。但在开始之前我们先了解一下:

无缝滚动的基本原理

如上图所示,我们将 1号、2号、3号,三张图片依次排成一排,从窗口中看,先出现的是 1号图,短暂停留后滚动到 2号,接着依次向后,也就是3号。如果要实现“无缝滚动”,而不是到3号就结束了,那么接下来就应该出现1号了,因为这样才能形成了一种视觉上的循环滚动,这也就是为什么我们需要在3号后面补充一张 1号图的原因。当这张“假”的1号图,完全滚动到充满屏幕时,我们就迅速把整体移动到最开始的状态。由于这种“瞬移”,从窗口看显示的都是完整的1号图,所以视觉上,并感受不到背后的“突变”。由于用户可以通过手势左右滚动,所以反过来就要在开始位置补充一张3号图,这里就不再赘述了,这样一来,无论向左向右滚动,都会形成视觉上的无缝效果。

那么从前端的角度去实现这个会涉及到什么技术点呢?

  1. 位移的实现:我们可以借助 position 定位 + left/top 值的方式,也可以借助 transform: translate(x, y) 的方式,孰优孰劣,答案是后者更佳。感兴趣的可以阅读这篇参考文章 Why Moving Elements With Translate() Is Better Than Pos:abs Top/left
  2. “一令一动”:启动停止,启动停止。。。显然需要用到定时器。
  3. “动若脱兔”:也就是两屏之前的切换动画。比如上图中在第一屏的样式是 transform: translate(0px, 0px),而在第二屏是 transform: translate(-200px, 0px)。通常这个改变是需要一个快速的过渡动画的,而不是瞬间从1号“突变”到2号,这时候你就需要用到 transition: translate 0.3s ease; 来表明你希望这次的切换是一个平滑的过程。这里有个问题就是:当两个1号屏需要衔接上,进行位置重置时,是需要“突变”的,也就是上图中 transform: translate(-600px, 0px)transform: translate(0px, 0px)的过程。这样就意味着列表元素的transform 属性并不是一成不变的, 所以在“一令一动”的定时器开始下一屏切换之前你需要判断当前是否是临界状态,以设置不同的 transition 时长。
  4. 移动端下的手势操作:我们需要用到与触摸相关的三个事件,也就是: touchstart(手指接触屏幕)、touchmove(滑动中,会连续触发)和touchend(手指离开屏幕)。而我们要做的就是通过event.touches或者event.changedTouches拿到他们这些事件触发时的坐标信息。比如当用户touchstart触发时,我们记录开始的 x 轴坐标,touchmove触发时我们比较此时x 轴坐标与开始时的坐标的差值,借助 translate 移动同样的距离,以实现“跟手”的效果。而当touchend触发时,我们依然通过比较与开始坐标的差值来确认用户到底是要左滑还是右划。

条条大路通罗马

上面的介绍只是实现“无缝滚动”最常见的一种思路,淘宝似乎更聪明:我们知道用户手指在屏幕上,一次连续滑动的最远距离是不可能超过一个屏幕的宽度的,就比如我此时在2号屏,我最多滑到1号屏,或者3号屏,无论如何,我一次也不能滑到4号屏去。也就是说无论我们总的有多少屏,我们同时出现在屏幕的 DOM 最多是属于相邻的两个屏的。既然如此,我们可以把剩下的屏都置于一个等待队列里,让他们呆在屏幕外的一个固定位置上即可。这样一来每次滚动,浏览器重绘的面积只是二屏——当前屏和下一屏。而不是 n + 2,这无疑提示了性能。相信通过下面这张动态图,你应该可以明白我的意思了:

通过上图我们可以发现:同一时间有且仅有两个屏在位移,每波切换过程,有三个屏的 DOM 位置发生了变化。下面的两张截图也很好的验证了我的猜想:

image
image

阿里毕竟是阿里,大佬毕竟是大佬,不得不佩服!相比之下,京东就粗糙了些,用的是我最开始介绍的那种基本原理实现的。虽然两者在实现无缝滚动的原理上存在差异,但是借助的技术基本上都是我上面列出的四条。淘宝的实现算法虽然很好,但是有一个致命的问题,他很难满足点击切换的需求,如果下面的“小圆点指示器”是可以点击跳转的,你试想一下他怎么从第二屏跳转到第四屏?但这种需求在 PC 端的“无缝滚动” 中很常见,作为一名开发者你不得不想在前面。而两者也都存在我开篇提到的缺少对用户手势意图揣摩的问题,所以是时候推出新的解决方案了:

seamless-scroll

这是我最近折腾的一款无缝滚动插件,它同时满足移动端和 PC 端的开发场景,借助 requestAnimationFrametranslate 实现。提供类似原生 App 的体验,添加了对“快速滑动切换”和“缓慢拖动”等手势场景的处理。不依赖任何现存的框架或组件库,纯 JS ,也就意味着你无论在 Vue 还是 React 项目中都可以直接使用。支持 npm 安装 和 CDN 链接 引入,📦Gzip Size< 3KB,支持 IE10+IOS9+Andorid5+ 和现代浏览器。使用起来也很简单,它会暴露一个 SeamlessScroll 的构造函数,你可以借助 new 关键字创建一个“无缝滚动”实例,通过传递参数,你可以自定义动画速度、是否自动播放等行为,创建的实例也提供 startstopgo 等方法让你可以方便的控制播放的启动停止或者直接跳转到某个索引位置等。

Github 仓库地址扫码体验移动端点击预览 PC 端在 React 中使用的示例代码

真机 iPhone 和 小米5 上测试过,体验还是非常流畅的,下图是谷歌浏览器 Performance 面板的截图,上方 FPS 一栏形成了连续稳定的 5 个绿色小块,反应了5次移动过程中的 FPS 的变化。这些绿色小柱越高表示帧率越高,体验就越流畅,反之如果出现红色小柱,则很可能存在卡顿。

下面我就介绍一下我的实现思路:

  首先选取基本的实现原理:上面介绍的“淘宝式”和“京东式”两种“无缝滚动”原理,因为要满足直接跳转的需求,所以选择了后者。

  技术选型再思考:上面介绍了在实现“无缝滚动”中需要用到的四个技术点,1,2,4依然适用,但在“动若脱兔”的环节我们也许可以换个思路。上面我们说到这个过渡动画可以利用 transition 来实现,它的表现非常流畅。不过我们知道动画的本质其实就是一组连续运动的画面,既然如此,我们是否可以通过连续不断的在短时间内移动一小段距离来实现类似动画的效果呢?当然可以。我们不妨把“无缝滚动”的过程抽象为两大状态的循环组合——静止状态和动画状态

  静止状态下我们通过定时器延迟一段时间后开启下一波的动画状态,并为这个动画状态确认目标位置,而在动画状态下我们一步一步小心的“挪动”,随时关注自己是否已经到达了目标位置,如果到达了,我们就停止,重新回归静止状态,并由它确认我们下一波的移动。思路已经很清晰了!那么是否意味着我们已经可以通过两个 setTimeout 来完成这件事情呢?答案是 No,因为理论和现实之间的距离就像爱情一样。

  浏览器的渲染并不是一蹴而就的——问题就出在“连续不断的在短时间内移动一小段距离”上,要知道在这个过程中你要实时确认自己是否已经到达目标位置,那么就会涉及到读取当前的 translate 偏移量和设置新的translate的工作。如此频繁的 DOM 读写势必会导致卡顿的!我们都知道 JS 直接操作 DOM 是很昂贵了!不然 Vue 也不需要 VNode 了,对吧?那么如何优化读写的过程就成了保证“动画”流畅性的关键!

  的问题很好解决,我们可以在内部维持一个偏移量的状态值,任何对实际 DOMtranslate 值的修改都需要先反应在这个值上,类似于 VueReact 虚拟 DOM 树的作用,只不过我们这个更简单,只是一个实际偏移量的映射,这样每次就不需要从实际 DOM 中读取当前的偏移量了。

  的过程是无法避免的,不修改 DOM 用户什么变化也看不到,动画何从说起!

  我们已经知道通过 translate 使元素的发生位移相比于 定位 + left/top 的方式,它的优点在于不会导致浏览器的重排。而在这种场景下使用 translate3d 的效果也只会更差,因为通过 JS 频繁更改该属性,浏览器每次都需要比较 xyz 三个轴上的变换,强制 GPU 加速似乎成了玄学。所以当“无缝滚动”是沿着 X 方向的,那么写入的最佳方式其实是 translateX,同理 Y 轴方向是 translateY

  写入的时机是我们的主要发力点。如果你希望用户感受到的画面是连续的,那么也就意味着每 1000 / 60 ms 也就是 16.67 ms 左右就要进行一次这种写入。我们知道 setTimeout 实际上并不准确,它依靠浏览器内置时钟的更新频率,还面临这异步队列的问题,就好比下面的一段代码,我们期望 setTimeout 3 秒后打印 Done!,但实际需要 10 秒,它会被同步进程“阻塞”!

// 期望 3 秒后打印 Done!
setTimeout(function () {
    console.log("Done!");
}, 1000 * 3);
// 这个同步进程需要 10s 才能从执行栈里推出,所以 10s 后才会打印 Done!
function waitSeconds(wait) {
    var start = Date.now();
    while (start + 1000 * wait > Date.now()) {}
}
waitSeconds(10);

  得益于 requestAnimationFrame 这个 API 的存在,才使得我们通过这种思路实现流畅的“无缝滚动”成为了可能。

window.requestAnimationFrame()告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

  对 transform 的修改会导致重绘,也就意味着我们通过类似递归的方式可以形成一组连续的动画。复制下面这段代码到浏览器控制台里,体验一下页面漂移的感觉。

var target = 200
var offset = 0
function moveBody(){
	document.body.style.transform = `translateX(${++offset}px)`
	if(offset<target){
		requestAnimationFrame(moveBody)
	}
}
requestAnimationFrame(moveBody)

于是按照这个思路 seamless-scroll 就诞生了。还有更多设计细节,比如如何实现暂停继续,如何通知外部当前索引值的变化,如何揣摩用户的手势意图,如果选取最优的移动路径,比如从 第5屏 到 第2屏,按照 5,1,2 的顺序移动是优于 5,4,3,2 的顺序的,因为这才会真正形成视觉上的 “无缝” 效果,而不是倒回去。有兴趣的可以读一下我的源码。我也做了诸如添加 will-change 属性等的优化尝试,但是效果似乎不明显。欢迎大佬们批评指正,当然 PR 我是更欢迎的,特别是能显著提升性能的那种😝。接下来就简单介绍一个这款插件的使用

安装

npm i seamless-scroll
# 或者
yarn add seamless-scroll

快速开始

建议参考这个 Demo 项目, 它包括 PC 端 + 移动端的示例代码

为了插件更好的运行,页面的 DOM 结构需按照下面的约定设置:

<!-- 容器 -->
<div id="box">
  <!-- 列表 -->
  <ul>
    <!-- 子元素们 -->
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
  </ul>
  <!-- 此处可以添加“小圆点指示器”或“前进后退箭头”等 DOM 元素-->
</div>

初始化一个“无缝滚动”实例,就是这么简单🍳,一个棒棒哒💯的 banner 轮播就完成了:

// 引入插件
import SeamlessScroll from 'seamless-scroll';

// 创建实例
const scroller = new SeamlessScroll({
  el: 'box',
  direction: 'left',
  width: 375,
  height: 175,
  autoPlay: false
});

// 用户点击“开始按钮”时,调用实例的 start 方法,开始播放
const startBtn = document.getElementById('start-btn');
startBtn.addEventListener('click', function() {
  scroller.start();
});

参数

参数名 说明 可选值 默认值 必填
el 容器元素。可以是已经获取到的 DOM 对象,也可以是元素 id DOMElementString
direction 滚动的方向 left, right, up, down left
width 容器的宽度,单位 px Number
height 容器的高度,单位 px Number
delay 每屏停留的时间,单位 ms Number 3000
duration 滚动一屏需要的时间,单位 ms Number 300
activeIndex 默认显示的元素在列表中的索引,从 0 开始 Number 0
autoPlay 是否自动开始播放,如果设置为 false,稍后可以调用实例的 start 方法手动开始 Boolean true
prevent 阻止页面滚动,通常用于竖向播放的情况,设置为 true 时,可避免用户在组件内的滑动手势导致的页面上下滚动 Boolean true
onChange 屏与屏之间切换时的回调函数,入参为当前屏的索引,可用于自定义小圆点指示器这样的场景 Function

实例方法

start

非自动播放时,调用此方法可手动开始播放。只能调用一次,仅限于 autoPlayfalse 且从未开始的情况下使用。

stop

停止播放。

continue

继续播放。配合 stop 方法使用。

go

直接滚动的某个索引的位置,或者向某个方向滚动一屏。你可以借助此方法实现快速跳转或者前后切换的业务场景。该方法跳转的逻辑是选取目标屏与当前屏的最短距离进行位移,比如从 第5屏第2屏,会按照 5,1,2 的顺序移动,而不是 5,4,3,2 的顺序,这样的好处在于真正形成视觉上的 “无缝” 效果。

  • 示例:scroller.go(0)scroller.go('left')
  • 参数类型:Numberleft, right, up, down

resize

更新容器的宽高。

  • 示例:scroller.resize(375, 175) // width, height
  • 参数类型:Number,单位 px

比如下面这段代码,就是在监听到浏览器窗口大小改变后,重新设置了容器的宽高。

(function(vm) {
  var resizing,
    resizeTimer,
    requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;

  vm.resizeHandler = function() {
    if (!resizing) {
      // 第一次触发,停止 scroller 的滚动
      resizing = true;
      scroller.stop();
    }
    resizeTimer && clearTimeout(resizeTimer);
    resizeTimer = setTimeout(() => {
      // 停下来后,重设 scroller 的宽高,并继续之前的播放
      resizing = false;
      scroller.resize(document.body.clientWidth, 300);
      requestAnimationFrame(function() {
        scroller.continue();
      });
    }, 100);
  };
  window.addEventListener('resize', vm.resizeHandler);
})(this);

不要忘记在离开页面时,清除监听器!下面是在 VuebeforeDestroy 钩子中清除对窗口变化监听的示例

beforeDestroy(){
  window.removeEventListener('resize', this.resizeHandler);
}

destroy

销毁实例,恢复元素的默认样式

下面是在 ReactcomponentWillUnmount 钩子中调用该方法的示例:

componentWillUnmount(){
  this.scroller.destroy()
}

总结

这款插件在保障流畅性的前提下,不仅支持了对用户手势意图的智能识别,也足以满足大部分 PC 端和移动端项目的业务需求。而且非常轻量,使用起来也很简单。希望能帮助到有这方面需求的小伙伴们,如果大家有好的建议也欢迎留言交流。

Mac 福利:从前端入手,搞定 iCloud 自动同步 node_modules 的痛点

我要解决什么痛点?

很多前端小伙伴都在使用 Mac 作为自己的主力开发机型,而苹果自家 iCloud 同步的便利性相信也不需要我多解释,特别是当你有多台苹果设备时,那种无缝的体验,一旦用了就回不去。可作为一名前端开发,今天,无论你使用的是 Vue 还是 React 亦或任何其他的前端技术栈,几乎是不可能避开 npm 的,但是如果你想把自己的代码也备份到 iCloud,为它上一份双保险(git仓库一份)。那么你会发现,当 iCloud 自动同步 node_modules 时,那是一种多么痛的领悟 —— 无尽的文件、嵌套的层级、庞大的体积等等,而 node_modules 也并没有同步的必要,你只需要一个 package.jsonlock file 就可以随时随地,无缝还原。在这方面,iCloud 的糟糕体验使得你不得不打消用它来备份前端代码的念头。

有没有现行的解决之道?

如何避免 iCloud 自动同步 node_modules?方法还是有的,你只需要创建一个 node_modules.nosync 文件夹,然后为它制作一个名为 node_modules 的替身(快捷方式)即可。iCloud 不会同步以 .nosync 结尾的文件或者文件夹,而这样做也不会影响到你的开发。

mkdir node_modules.nosync && ln -s node_modules.nosync node_modules

但这并不是最佳实践,所以当你用谷歌搜索 iCloud node_modules 这些关键字的时候,你会发现大量用户抱怨这个,我也是其中之一,并给苹果提交的反馈,但是苹果似乎并没有积极解决这个问题的态度,网上提供的方案也大体和我上面说的一样。

一个前端攻城狮的反击!

难道就要这样将就?其实上面的那行命令完全可以做成一个可执行文件,这样每次需要时,只需要执行一个简短的命令就 OK 了。再联想到我们平时用 npm 全局安装的一些 CLI 工具,比如vue initcreate-react-appnodemon等等,我觉得:身为一个前端,我应该做点什么了。于是 —— nosync-icloud 就诞生了👏👏👏🎉🎉🎉。

Version Downloads Commit Issues License

如何使用

1. 安装
sudo npm i -g nosync-icloud
# or
sudo yarn global add nosync-icloud

安装成功后会创建 nosyncns (简写,作用相同)的全局命令。

2. 使用

打开 iCloud,进入任何一个你的项目中,在终端中执行 ns 即可。ns 命令会根据你当前项目结构,自动处理 node_modules,如果你之前没有安装过 node_modules,它会提供三种可选安装方式 —— npmyarncnpm,当然你也可以选择稍后安装。安装完成后,你可以选择是否将 node_modules* 的忽略规则添加 .gitignore

3 其他指令

nosync-icloud 不仅可以 禁止 iCloud 自动同步 node_modules,你还可以通过 ns -f foo 指定任何你不希望同步的文件夹。

指令 简写 作用
--version -v 查看当前版本号
--help -h 输出帮助信息
--folder -f 指定不希望同步到 iCloud 中的文件夹,默认是 node_modules,如: ns -f foo
--git -g 跳过提示,直接添加 .gitignore,可选:ns -g false,跳过提示,不添加 .gitignore

写在后面

希望 ns 命令能成为使用 Mac 开发的前端小伙伴们回不去的习惯,也许以后你进入项目的第一件事不是执行npm i 或者 yarn,而是执行 ns,为自己的前端项目上一份双保险。如果它实实在在给你带来了便利,不妨去 收藏 一下,你也可以把这个好用的工具分享给身边的其他人。如果你有任何建议或问题,欢迎提交 IssuePR

移动端调试痛点?——送你五款前端开发利器

image

之所以写这个总结,还要从上周的一次移动端项目的 debug 说起。那天,测试小姐姐拿着自己的 iphone6s 过来找我,说页面打不开。我想:这怎么可能,我手机里挺好的呀,Chrome调试工具也没报错呀!就把她手机拿过来看了看,发现一进去还真就是一片空白。WTF(手动黑人问号)!!!那问题就来了,开发环境下没报错,可真机又出现了意料之外的情况,而且没法像 PC端 那样祭出 F12 大法,怎么定位问题并解决呢?最后凭借着我(谷歌)的聪明才智,找到了媲美 PC端 调试体验的方式。在此总结一波,献给各位被移动端真机调试折磨,而又无从下手的前端er们,话休烦絮,直接奉上:

1. vConsole 推荐指数:★★★☆☆

腾讯出品的 Web 调试面板,相信不少前端小伙伴都用过。vConsole 会在你网页中加一个悬浮的小按钮,可以点击它来打开关闭调试面板,并查看 DOMConsoleNetwork本地存储 等信息。基本可以满足普通前端开发的需求。使用方法也很简单,通过npm安装或者直接在需要的页面引入 js文件 ,然后 new VConsole() 就可以了。不熟悉的小伙伴可以直接去官方的 GitHub 看 README。但是它并没有解决我的问题,因为我的 bug 严重到一进页面就报错,脆弱的 javascript 直接原地爆炸💥,页面一片空白😂。

同类产品 eruda

2. Charles 推荐指数:★★☆☆☆

Charles 是一款强大的抓包工具,可以截取包括 https 在内的各种网络请求并方便的查看具体信息。有 MacWindowsLinux多版本,通过配置 WIFI 代理,也可以拦截手机发出的请求。毕竟前端相当一部分报错是网络错误或数据不符合预期导致的(甩锅后端😄)。所以通过拦截 http 请求,查看具体的请求信息和数据,能获取很多有用的信息,可以在一定程度上帮助 debug。但是该软件是付费的(希望大家支持正版,要记住你也是一位开发),而且它定位不了 js 的报错,所以只能作为一个辅助工具。至于使用方法,网上很多介绍—— 此处一枚

3. weinre 推荐指数:★★★☆☆

weinre是一款很不错的网页检查工具,可以通过在本地启动一个 weinre 服务,并向手机网页嵌入一段 js 脚本来实现和电脑的通信,已达到类似浏览器开发工具那样的的调试效果,它的操作界面和 vConsole 差不多,主要包括查看 DOMConsoleNetwork 等,只不过这一切是在电脑上操作,而不是在手机上。微信web开发者工具的移动调试也是借助于此。附上一篇简单的使用介绍。因为我的 js 早就原地爆炸💥,它和 vConsole 一样,并没有帮到我什么。

4. Mac + IOS + Safari 推荐指数:★★★★☆

如果你手上有一台 Mac 电脑和一部苹果手机,那么恭喜你,你离解决 bug 只差我这一篇博客了。(手动滑稽)

第一步:打开苹果手机 设置 > Safari浏览器 > 高级 > Web检查器

第二步: 打开 Mac 上的 Safari浏览器 > 偏好设置 > 高级 > 在菜单栏中显示“开发”菜单

第三步: 用数据线连接你的 Mac 电脑和苹果手机,并选择信任设备。然后在手机的 Safari浏览器 中打开你需要调试的页面,并在电脑上点击下图红框的位置。

第四步:点击之后就会出现如下图所示的,几乎和电脑一样的调试界面,怎么操作,我想各位大佬也不用我多啰嗦了吧!我就是通过这种方式发现 js 的报错,并成功解决问题,赢得小姐姐认可的😎。

5. Chrome浏览器 + Android 推荐指数:★★★★★

很多小伙伴可能不使用 Mac 或者不习惯 Safari浏览器 的开发者工具,没关系,谷歌也有类似的工具,而且更符合大家的使用习惯。有梯子的小伙伴,可以直接看谷歌官方文档

第一步:打开 Android 手机 设置 > 开发者选项 > USB调试。设置里面没有 开发者选项 的,自行百度

第二步:通过数据线连接你的电脑和 Android 手机,会弹出如下界面,点击 确定

第三步:给你的 Android 手机下载一个手机版的 Chrome浏览器 (各大应用商店自行搜索),并在手机上的 Chrome浏览器 中打开你需要调试的页面。

第四步:打开你电脑上的 Chrome浏览器 ,按下图标注顺序,依次点开。我使用的是 小米5,你可以看到左侧有 MI 5 已连接的字样。划线的地方分别是手机上 Chrome浏览器 和自带浏览器 WebView 下打开的页面。

第五步: 每个页面右侧都有一个 Inspect 检查的按钮,点击就会出现你熟悉的画面,后面就不用解释了吧!走你🚀。

公司的小伙告诉我,这种方法他需要爬梯子才能用,爬不上去的小伙伴可以关注我,我后面计划出一篇介绍如何自己搭梯子的博客 送你一架小飞机~~

总结

工欲善其事必先利其器,没有好的调试工具或方法,移动端真机下的 debug 简直是前端的噩梦。但是有了这些好用的方法,我想各位优秀的前端大佬,帮妹子修复个小 bug 还是 so easy 的。如果各位大佬有好的意见或者有其他的解决方案,也欢迎评论区交流。

[译] 优化 MP4 视频以获得更快的流传输速度

优化 MP4 视频以获得更快的流传输速度

随着 Flash 的衰落移动设备的爆炸式增长,越来越多的内容正在以 HTML5 视频的方式发布。你可以通过 用 HTML5 视频片段代替 GIF 动画 的方式来优化你的网站速度。然而,视频文件本身就有大量可优化的地方,你可以借此提升它们的性能。

其中最重要的一点是视频文件必须经过适当优化才能作为 HTML5 视频在线播放。如果没有这种优化,视频可能会延迟数百毫秒,而只是试图播放视频的访问者也可能会浪费兆字节的带宽。在这篇文章中,我将向你展示如何优化视频文件以获得更快的流传输速度。

MP4 流媒体是如何工作的

在我们 上一篇文章 的讨论中,HTML5 视频是一种跨浏览器观看视频的方式,不需要类似 Flash 的插件。截止到 2016 年,存储在 MP4 容器文件中的 H.264 编码视频(下文将简称为 MP4 视频)已成为所有在线 HTML5 视频的标准格式。所以当我们讨论如何优化 HTML5 视频,其实我们是在讨论如何优化 MP4 视频以获取更快的播放。而我们优化的方式与 MP4 文件的结构以及流媒体传输的工作原理息息相关。

MP4 文件由叫做 原子 的数据块组成。这些原子用以存储字幕和章节等内容, 当然也包括视频和音频等显而易见的数据。而视频和音频原子的元数据,以及有关如何播放视频的信息,如尺寸和每秒的帧数,则存储在叫做 moov 的特殊原子中。你可以认为 moov 原子是某种意义上的 MP4 文件目录

当你播放视频时,程序会查找 MP4 文件,定位 moov 原子的位置,然后借此去查找视频和音频的起始位置来开始播放。遗憾的是,原子可能以任意顺序排列,所以程序一开始并不知道 moov 原子在哪里。如果你已经拥有整个视频文件,查找 moov 原子是完全没问题的。但如果暂时没有整个文件,比如流传输 HTML5 视频时,就需要另辟蹊径了。这才是流媒体的重点!你无需先下载整个视频即可开始观看。

当流传输时,你的浏览器请求视频资源并开始接收文件的开始部分。程序查找 moov 原子是否在开始的部分。如果 moov 原子不在开始位置,它必须下载整个文件才能尝试找到 moov,或者浏览器可以从最末端的数据开始下载视频文件的不同小片段,以试图找到 moov原子。

所有这些试图找到 moov 的行为浪费了时间和带宽。遗憾的是,在找到 moov 之前,视频是无法播放的。 我们可以在下面的屏幕截图中看到浏览器的瀑布图,该浏览器试图使用 HTML5 视频来流传输未优化的 MP4 文件:

mp4-no-moov

你可以看到浏览器在播放视频之前发起了三次请求。 在第一次请求中,浏览器通过 HTTP range request 下载了 552 KB 的第一部分视频。 我们可以通过 HTTP 状态码 206 Partial Content 以及查看响应头的详细信息来发现这些。然而,moov 原子并未包含在内,所以浏览器无法开始视频播放。接下来,浏览器通过 HTTP range request 获取了后面的 21 KB 视频文件。它包含 moov 原子,告诉浏览器视频和音频流的开始位置。最后,浏览器发起了第三个也是最后一个 HTTP range request,以获取音频/视频数据并可以开始播放视频。这导致浪费了超过半兆字节的带宽以及 210 ms 的播放延迟!仅仅是因为浏览器找不到 moov 原子。

如果你的服务器没有配置 HTTP range request,情况甚至会更糟:浏览器无法跳过查找 moov 这一步,以至于不得不下载整个文件。这也是你需要 支持部分下载来优化你的网站 的另一个原因。

为 HTML5 流传输准备 MP4 视频的理想方法是(重新)组织文件,以便 moov 处于最开始的位置。这样一来,就可以避免浏览器下载整个视频或者为了尝试找到 moov 而浪费时间发起额外的请求。具有流优化视频的网站的瀑布图如下:

mp4-fast-start

开始位置包含 moov 的文件,视频会下载播放得更快,带来的结果是更好的用户体验。

如何优化 MP4 视频以获得更快的流传输速度

我们已经知道为了优化视频的 HTML5 流传输,你需要重新组织 MP4 原子,以便 moov 原子处于开始位置。 那么我们如何做呢?大部分视频编码软件都有一个 **“针对网页优化”**或 **“针对流传输优化”**的选项来做这件事。当你创建视频时,你需要查看你视频编码设置以确认它是优化的。例如,在下面的屏幕截图中,我们可以看到开源视频编码工具 Handbrake“Web Optimized” 选项,用来将 moov 原子放在开始位置:

handbrake

如果你从原始资源视频创建 MP4 文件,这是一个可行的解决方案,但是如果你已经有一个 MP4 文件了呢?

你可以重组已存在的视频来优化它在网页流传输的表现。例如,开源的命令行视频编码工具 FFMpeg 就可以重组 MP4 文件的结构来让 moov 原子处于开始位置。不同于初始编码视频那样非常耗时和占用 CPU ,重组文件很容易操作。而且,他不会对原始视频质量造成任何影响。以下是用 ffmpeg 来优化一个叫做 input.mp4 的视频文件流传输的例子,导出的视频叫做 output.mp4

ffmpeg -i input.mp4 -movflags faststart -acodec copy -vcodec copy output.mp4

-movflags faststart 参数告诉 ffmpeg 把 MP4 视频的原子们重新排序以使得 moov 位于开始位置。我们同样指示 ffmpeg 拷贝视频和音频数据而不是重新编码,所以没有任何改变。

为了 Rigor 网站的顾客,我们向 Rigor 优化添加了新的检测工具。我们的性能分析和优化产品,可以检测还没有针对 HTM5 视频流传输进行优化的 MP4 文件。如果你只是想快速检测自己网站,你可以用 我们免费的性能报告

总结

不管你是将 GIF 动画转化为 MP4 视频片段,还是已经有一堆 MP4 视频,如果你优化文件结构,你都可以使得这些视频加载并开始播放de更快。通过重排原子让 moov 原子处于开始位置,使得浏览器跳过发送额外的 HTTP range request,避免尝试定位 moov 原子。这允许浏览器立即开始流视频传输。你通常可以在最初创建视频时配置一个选项,以优化流传输。 如果你已有文件,则可以使用 ffmpeg 之类的程序对文件进行重排,而不更改其内容。

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

如何保证你执行的 JS 就是你想执行的 JS?

背景

我网站执行的 JS 当然是我自己写的 JS 呀,难不成还有安全问题🤔?但想想,很多时候网站的 JS 是从第三方的 CDN 服务加载下来的,如果CDN服务器受到了攻击,JS 文件被篡改,就会带来安全风险,如何保证我们网站运行的这些脚本文件是未被修改的呢?

还有一种情况就是 html 中可能有一些 inline script,这些 JS 可能是在服务端渲染 html 模版的时候注入的,如果网络传输过程中被抓包篡改了怎么办?

伪代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <!-- inject-placeholader -->
    <div id="root"></div>
  </body>
</html>
router.get("/", async (ctx) => {
  const htmlTpl = await getHtmlTpl();
  const script = `window.__SOME_DATA__ = ${JSON.stringify(__SOME_DATA__)};`;
  ctx.type = "html";
  ctx.body = htmlTpl.replace("<!-- inject-placeholader -->", `<script>${script}</script>`);
});

先来看看传统软件是怎么保证用户获取到的内容是未篡改的。比如 linuxkit/linuxkit,他的发布页面会附上 Checksums

image

这些 hash 值是根据文件内容,通过固定的算法得到的,比如上面的 SHA256。这样获取到文件的用户用同样的算法做一次计算,如果得到的 hash 值和作者标明的一致,就可以验证软件没有被别人篡改过的。

回到前端,前端有没有类似的安全方案呢?答案是肯定的。

1. Subresource Integrity(SRI)

我们给 <script><link> 标签添加 integrity 属性,属性值是根据文件内容做 hash 算法得到的一个字符串。形如:

<link href="//somecdn.com/foo.css" rel="stylesheet" integrity="sha256-t7Z7PgokIxRooJ8azMRTqZZIdgaQX6ViGg3pn3pxZZs= sha384-KJi9xVfT8JzG/tFq6Dpgw6URtNE3WK83VaQOWpfHsVAN6Az5+AjliGZuSiiWd4ah" crossorigin="anonymous">

<script src="//somecdn.com/bar.js" integrity="sha256-XXVAhVe8STxZbyQhPOwZpZmx3X9iHnnrBPHUN/4vooc= sha384-438vOegRAvOckkDAIIIL8+k0JhRCRfY7Q2QXLjgFOHQbhyFK/YwGIDJBxYCdaHjA" crossorigin="anonymous"></script>

其中的 sha256sha384 是 hash 算法,而 sha256-sha384- 后面的部分是 hash code, 对于跨域的脚本请求,脚本服务器需要设置 CORS 响应头,允许跨域站点访问他的内容 Access-Control-Allow-Origin: *<script><link> 标签上也需要添加 crossorigin 属性,anonymous 代表请求不携带 cookie。

对于设置了 SRI 的脚本或样式,浏览器会对请求下载的文件内容做同样的 hash 算法,如果 hash code 不匹配,就会拒绝执行。
image

webpack 的项目可以通过 webpack-subresource-integrity 在打包时自动添加 Integrity 和 anonymous,配置如图:

image
image

2. Content-Security-Policy (CSP)

对于一些行内脚本,我们可以通过设置 CSP 的 方式来校验。比如:

router.get("/", async (ctx) => {
  const htmlTpl = await getHtmlTpl();
  const script = `window.__SOME_DATA__ = ${JSON.stringify(__SOME_DATA__)};`;
  const hash = require("crypto").createHash("sha256").update(script).digest("base64");
  ctx.set("Content-Security-Policy", `script-src 'self' 'sha256-${hash}'`);
  ctx.type = "html";
  ctx.body = htmlTpl.replace("<!-- inject-placeholader -->", `<script>${script}</script>`);
});

上面的 CSP 设置保证了只有 hash 值和我们给定的 hash 值一致 inline-script 才会被执行,对于不符合的 inline-script,会抛出如下错误:

image

不过话说回来,既然算法是固定的,交付 hash code 的过程也是通过网络传输到前端的(html 内容,response header),如果有人能在网络传输的过程中修改响应,那么他把恶意代码做一次同样的算法,然后把 hash code 也改掉,还是无法避免不安全脚本的出现。这就要求我们要保证网络传输的安全。一方面我们需要给网站配置 https 避免明文传输,另一方面也要告知用户不要在访问网站的时候使用不可信代理。

补充

顺便说一下,保证内容可靠性的另一种常见做法。对接过微信公众号消息的同学可能有印象,微信的服务器会给我们的服务器推送消息,我们如何保证消息是微信的服务器发送过来的,而不是其他人伪造的呢?做法就是微信和我们自己的服务器都持有同一个 Token。微信发过来的消息中有一个签名 signature,这个签名就是由请求的内容和这个 Token 共同参与生成的。只要保证微信侧和我们自己的服务持有同一个Token,对同样的内容,做同样算法的签名就可以,如果最终得到的签名一致就说明请求确实是微信发给我们的。可以参考我之前写过的 Node.js 对接微信消息通知的 [ 示例

image

这种做法不同于上面的一点是双方都保留了一个不为第三方所知的 Token,只要 Token 没泄露,即使别人知道你们的签名算法是啥,也无法伪造出一个签名。这种方式并不适用于前端,因为前端无法不通过网络把一个 “Token” 预先埋在所有用户的系统里。

参考链接

https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity
https://developer.mozilla.org/zh-CN/docs/Web/HTML/CORS_settings_attributes
https://github.com/waysact/webpack-subresource-integrity
https://webpack.js.org/configuration/output/#outputcrossoriginloading
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src

Vue CLI 2&3 下的项目优化实践 —— CDN + Gzip + Prerender

前言

这些优化方案适用于 Vue CLI 2Vue CLI 3 ,文章主要基于Vue CLI 2进行介绍,关于如何在Vue CLI 3中进行相关的webpack调整,我已经放在了 vue-cli3-optimization 这个仓库下,并配有详细的注释,且额外添加方便Sass使用的loader,使用Sass时无需再在每个需要引入变量和mixin的地方,每次都很麻烦的@import。下面将详细介绍这些优化方案的实践方式和效果:

和很多小伙伴一样,我在开发Vue项目时也是基于官方vue-cli@2webpack模版,但随着项目越做越大,依赖的第三方npm包越来越多,构建之后的文件也会越来越大,尤其是vendor.js,甚至会达到2M左右。再加上又是单页应用,这就会导致在网速较慢或者服务器带宽有限的情况出现长时间的白屏。为了解决这个问题,我做了一些探索,在几乎不需要改动业务代码的情况下,找到了三种有明显效果的优化方案 —— CDN + Gzip + Prerender。我把这些方法整理了一下,放在了 Github仓库 上,意图通过不同的分支来展示不同的优化方式,对Vue项目性能的影响。你可以直接克隆下来试一试,也得益于有git历史,你也可以很方便的查看具体的改动细节。下面我将通过一个简单的项目来展示这三种优化方案的效果。

一、首先准备一个简单的项目

通过vue-cli@2webpack模版生成,只包含最基础的Vue三件套 ———— vuevue-routervuex以及常用的element-uiaxios。拆分两个路由——“首页”和“通讯录”,通过axios异步获取一个通讯录名单,并利用element-ui的表格展示。直接build,不做任何优化处理,以作参照。

1.1 构建后文件说明:

  1. app.css: 压缩合并后的样式文件。
  2. app.js:主要包含项目中的App.vuemain.jsrouterstore等业务代码。
  3. vendor.js:主要包含项目依赖的诸如vuexaxios等第三方库的源码,这也是为什么这个文件如此之大的原因,下一步将探索如何优化这一块,毕竟随着项目的开发,依赖的库也能会越来越多。
  4. 数字.js:以0、1、2、3等数字开头的js文件,这些文件是各个路由切分出的代码块,因为我拆分了两个路由,并做了路由懒加载,所以出现了0和1两个js文件。
  5. mainfest.jsmainfest的英文有清单、名单的意思,该文件包含了加载和处理路由模块的逻辑

1.2 禁用浏览器缓存,网速限定为Fast 3G下的Network图(运行在本地的nginx服务器上

可以看到未经优化的base版本在Fast 3G的网络下大概需要7秒多的时间才加载完毕

二、CDN 优化

为了更好的开发体验,报错捕获,目前已经针对devbuild进行了区分,具体查看git记录,下面仅供参考。

  1. 将依赖的vuevue-routervuexelement-uiaxios这五个库,全部改为通过CDN链接获取。借助HtmlWebpackPlugin,可以方便的使用循环语法在index.html里插入jscssCDN链接。这里的CDN大部分使用的 jsDelivr 提供的。
<!-- CDN文件,配置在config/index.js下 -->
<% for (var i in htmlWebpackPlugin.options.css) { %>
<link href="<%= htmlWebpackPlugin.options.css[i] %>" rel="stylesheet">
<% } %>
<% for (var i in htmlWebpackPlugin.options.js) { %>
<script src="<%= htmlWebpackPlugin.options.js[i] %>"></script>
<% } %>
  1. build/webpack.base.conf.js中添加如下代码,这使得在使用CDN引入外部文件的情况下,依然可以在项目中使用import的语法来引入这些第三方库,也就意味着你不需要改动项目的代码,这里的键名是importnpm包名,键值是该库暴露的全局变量。 webpack文档参考链接
  externals: {
    'vue': 'Vue',
    'vue-router': 'VueRouter',
    'vuex': 'Vuex',
    'element-ui':'ELEMENT',
    'axios':'axios'
  }
  1. 卸载依赖的npm包,npm uninstall axios element-ui vue vue-router vuex
  2. 删除main.jselement-ui相关代码。

具体细节可以查看git的历史记录

2.1 比对添加 CDN 前后构建的文件:

优化后:

优化前:

可以看出:

  1. app.css: 因为不再通过import 'element-ui/lib/theme-chalk/index.css',而是直接通过CDN链接的方式引入element-ui样式,使得文件小到了bytes级别,因为它现在仅包含少量的项目的css
  2. app.js:几乎无变化,因为这里面主要还是自己业务的代码。
  3. vendor.js:将5个依赖的js全部转为CDN链接后,已经小到了不足1KB,其实里面已经没有任何第三方库了。
  4. 数字.jsmainfest.js:这些文件本来就很小,变化几乎可以忽略。

2.2 同样,禁用浏览器缓存,网速限定为Fast 3G下的Network图(运行在本地的nginx服务器上

可以看出相同的网络环境下,加载从原来的7秒多,提速到现在的3秒多,提升非常明显。而且更重要的一点是原本的方式,所有
jscss等静态资源都是请求的我们自己的nginx服务器,而现在大部分的静态资源都请求的是第三方的CDN资源,
这不仅可以带来速度上的提升,在高并发的时候,这无疑大大降低的自己服务器的带宽压力,想象一下原来首屏900多KB的文件
现在仅剩20KB是请求自己服务器的!

三、Gzip 优化

使用Gzip两个明显的好处,一是可以减少存储空间,二是通过网络传输文件时,可以减少传输的时间。

3.1 如何开启gzip压缩

开启gzip的方式主要是通过修改服务器配置,以nginx服务器为例,下图是,使用同一套代码,在仅改变服务器的gzip开关状态的情况下的Network对比图

未开启gzip压缩:

开启gzip压缩:

开启gzip压缩后的响应头

从上图可以明显看出开启gzip前后,文件大小有三四倍的差距,加载速度也从原来的7秒多,提升到3秒多

附上nginx的配置方式

http {
  gzip on;
  gzip_static on;
  gzip_min_length 1024;
  gzip_buffers 4 16k;
  gzip_comp_level 2;
  gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;
  gzip_vary off;
  gzip_disable "MSIE [1-6]\.";
}

3.2 前端能为gzip做点什么

我们都知道config/index.js里有一个productionGzip的选项,那么它是做什么用的?我们尝试执行npm install --save-dev [email protected],并把productionGzip设置为true,重新build,放在nginx服务器下,看看有什么区别:

我们会发现构建之后的文件多了一些js.gzcss.gz的文件,而且vendor.js变得更小了,这其实是因为我们开启了nginxgzip_static on;选项,
如果gzip_static设置为on,那么就会使用同名的.gz文件,不会占用服务器的CPU资源去压缩。

3.3 前端快速搭建基于nodegzip服务

无法搭建nginx环境的前端小伙伴也可以按如下步骤快速启动一个带gzipexpress服务器

  1. 执行npm i express compression
  2. 在项目根目录下新建一个serve.js,并粘贴如下代码
  var express = require('express')
  var app = express()

  // 开启gzip压缩,如果你想关闭gzip,注释掉下面两行代码,重新执行`node server.js`
  var compression = require('compression')
  app.use(compression())

  app.use(express.static('dist'))
  app.listen(3000,function () {
    console.log('server is runing on http://localhost:3000')
  })
  1. 执行node server.js

下图是express开启gzip的响应头:

四、Prerender 预渲染

大家都是知道:常见的Vue单页应用构建之后的index.html只是一个包含根节点的空白页面,当所有需要的js加载完毕之后,才会开始解析并创建vnode,然后再渲染出真实的DOM。当这些js文件过大而网速又很慢或者出现意料之外的报错时,就会出现所谓的白屏,相信做Vue开发的小伙伴们一定都遇到过这种情况。而且单页应用还有一个很大的弊端就是对SEO很不友好。那么如何解决这些问题呢?—— SSR当然是很好的解决的方案,但这也意为着一定的学习成本和运维成本,而如果你已经有了一个现成的vue单页应用,转向SSR也并不是一个无缝的过程。那么预渲染就显得更加合适了。只需要安装一个webpack的插件 + 一些简单的webpack配置就可以解决上述的两个问题。

4.1 如何将单页应用转为预渲染

  1. 你需要将router设为history模式,并相应的调整服务器配置,这并不复杂
  2. npm i prerender-spa-plugin --save-dev

注意!!!预渲染需要下载 Chromium ,而由于你懂的原因,谷歌的东西在国内无法下载,所以在根目录添加了.npmrc文件,来使用淘宝镜像下载。参考链接。如果你的终端可以翻到国外,直接忽略这一步,你也许会喜欢小飞机

  1. build/webpack.prod.conf.js下添加如下配置(没有路由懒加载的情况)。
  const PrerenderSPAPlugin = require('prerender-spa-plugin')
  ...
  new PrerenderSPAPlugin({
    staticDir: config.build.assetsRoot,
    routes: [ '/', '/Contacts' ], // 需要预渲染的路由(视你的项目而定)
    minify: {
      collapseBooleanAttributes: true,
      collapseWhitespace: true,
      decodeEntities: true,
      keepClosingSlash: true,
      sortAttributes: true
    }
  })
  1. config/index.jsbuild中的assetsPublicPath字段设置为'/',这是因为当你使用预渲染时,路由组件会编译成相应文件夹下的index.html,它会依赖static目录下的文件,而如果使用相对路径则会导致依赖的路径错误,这也要求预渲染的项目最好是放在网站的根目录下(这个坑我已经在prerender-spa-plugin仓库提过ISSUE了,不过借助postProcess,自己再写一个正则表达式,也能实现,如果你有这方面的需求,可以参考下面 路由懒加载带来的坑)。
  2. 调整main.js
  new Vue({
    router,
    store,
    render: h => h(App)
  }).$mount('#app', true) // https://ssr.vuejs.org/zh/guide/hydration.html

执行npm run build,你会发现,dist目录和以往不太一样,不仅多了与指定路由同名的文件夹而且index.html早已渲染好了静态页面。

4.2 效果如何?

和之前一样,我们依然禁用缓存,将网速限定为Fast 3G(运行在本地的nginx服务器上)。可以看到,在vendor.js还没有加载完毕的时候(大概有700多kB,此时只加载了200多kB),页面已经完整的呈现出来了。事实上,只需要index.htmlapp.css加载完毕,页面的静态内容就可以很好的呈现了。预渲染对于这些有大量静态内容的页面,无疑是很好的选择。

4.3 路由懒加载带来的坑

如果你的项目没有做路由懒加载,那么你大可放心的按上面所说的去实践了。但如果你的项目里用了,你应该会看到webpackJsonp is not defined的报错。这个因为prerender-spa-plugin渲染静态页面时,也会将类似于<script src="/static/js/0.9231fc498af773fb2628.js" type="text/javascript" async charset="utf-8"></script>这样的异步script标签注入到生成的htmlhead标签内。这会导致它先于app.js,vendor.js,manifest.js(位于body底部)执行。(async只是不会阻塞后面的DOM解析,这并不意味这它最后执行)。而且当这些js加载完毕后,又会在head标签重复创建这个异步的script标签。虽然这个报错不会对程序造成影响,但是最好的方式,还是不要把这些异步组件直接渲染到最终的html中。好在prerender-spa-plugin提供了postProcess选项,可以在真正生成html文件之前做一次处理,这里我使用一个简单的正则表达式,将这些异步的script标签剔除。本分支已经使用了路由懒加载,你可以直接查看git历史,比对文件和base分支的变化来对你的项目进行相应调整。

  postProcess (renderedRoute) {
    renderedRoute.html = renderedRoute.html = renderedRoute.html.replace(/<script[^<]*src="[^<]*[0-9]+\.[0-9a-z]{20}\.js"><\/script>/g,function (target) {
      console.log(chalk.bgRed('\n\n剔除的懒加载标签:'), chalk.magenta(target))
      return ''
    })
    return renderedRoute
  }

除了这种解决方案,还有两种不推荐的解决方案:

  1. 索性不使用路由懒加载。
  2. HtmlWebpackPlugininject字段设置为'head',这样app.js,vendor.js,manifest.js就会插入到head里,并在异步的script标签上面。
    但由于普通的script是同步的,在他们全部加载完毕之前,页面是无法渲染的,也就违背了prerender的初衷,而且你还需要对main.js作如下修改,以确保Vue在实例化的时候可以找到<div id="app"></div>,并正确挂载。
    const app = new Vue({
      // ...
    })
    document.addEventListener('DOMContentLoaded', function () {
      app.$mount('#app')
    })

总结

虽然官方的脚手架已经提供很多开箱即用的优化,比如css压缩合并,js压缩与模块化,小图片转base64等等,但我们能做的还很多。我没有提及代码级别的优化细节,也是希望给大家提供一些可实践的方案。上述三种方案或多或少都会给你项目带来一些收益。优化也是一门玄学,可研究的东西很多。也希望其他小伙伴可以在评论区提供宝贵意见,或者直接向我的这个项目 vue-optimizationbase分支提交PR,好的方案我会采纳并整理。目前三种方案整合的最终结果我已经放在 master 分支下,你可以克隆下来并在此基础上开发你的项目。

Vue项目中最简单的使用集成UEditor方式,含图片上传

前言

封面是UEditor百度指数 折线图。虽然今天已经是 2018 年,且优秀的富文本编辑器层出不穷(包括移动端),但从图中可以看出UEditor仍然维持着较高的搜索热度。而不少公司和个人也仍然在项目中使用UEditor。目前,UEditor官网的最后一次版本更新是 1.4.3.3,这已经是 2016 年的事情了,而今天的前端开发,很多小伙伴都在使用VueReact 这种组件化的前端框架。这就导致在这些“现代”框架中集成UEditor变得很不平滑。所以才会有下图这些大量介绍如何在Vue项目中集成UEditor的博客:

为了提高代码的可复用性,也为了尽可能的不在业务代码中参杂UEditor的相关操作,我在几个月前,公司项目的开发中撸了一个组件,可以通过v-model双向绑定的方式来使用UEditor,简单到就像使用input框一样。当我撸完,感觉非常的Vue范儿。而且看了不少博客和GitHub项目,都没有类似的实现。于是我决定发布到 npm 上,帮助一众还在思考如何把UEditor集成到Vue项目中的小伙伴。几个月下来,基本已经稳定,所以,今天通过这篇博客,分享给大家。

先看效果图:

点击预览 仓库地址

Installation

npm i vue-ueditor-wrap
# 或者
yarn add vue-ueditor-wrap

Quick Start

基于 vue-cli 2.x 的完整 DEMO
基于 Nuxt 的服务端渲染 DEMO

  1. 下载 UEditor

    下载最新编译的 UEditor。官网目前最新的版本是1.4.3.3,存在诸多 BUG,例如 Issue1,且官方不再积极维护。为了世界的和平,针对一些常见 BUG,我进行了修复,并把编译好的文件放在了本仓库的 assets/downloads 目录下,你可以放心下载,当然你也可以自己 clone 官方源码编译

    将下载的压缩包解压并重命名为 UEditor(只需要选择一个你需要的版本,比如 utf8-php),放入你项目的 static 目录下。

    如果你使用的是 vue-cli 3.x,可以把 UEditor 文件夹放入项目的 public 目录下。

  2. 引入VueUeditorWrap组件

    import VueUeditorWrap from 'vue-ueditor-wrap' // ES6 Module
    // 或者
    const VueUeditorWrap = require('vue-ueditor-wrap') // CommonJS

    你也可以通过直接引入 CDN 链接的方式来使用,它会暴露一个全局的 VueUeditorWrap 变量(具体如何使用你可以阅读我的这篇博客或参考这个仓库)。

    <script src="https://cdn.jsdelivr.net/npm/vue-ueditor-wrap@latest/lib/vue-ueditor-wrap.min.js"></script>
  3. 注册组件

    components: {
      VueUeditorWrap
    }
    // 或者在 main.js 里将它注册为全局组件
    Vue.component('vue-ueditor-wrap', VueUeditorWrap)
  4. v-model绑定数据

    <vue-ueditor-wrap v-model="msg"></vue-ueditor-wrap>
    data () {
      return {
        msg: '<h2>Vue + UEditor + v-model双向绑定</h2>'
      }
    }

    至此你已经可以在页面中看到一个初始化之后的 UEditor 了,并且它已经成功和数据绑定了!👏👏👏

  5. 根据项目需求修改配置,完整配置选项查看 ueditor.config.js 源码或 官方文档

    <vue-ueditor-wrap v-model="msg" :config="myConfig"></vue-ueditor-wrap>
    data () {
      return {
        msg: '<h2>Vue + UEditor + v-model双向绑定</h2>',
        myConfig: {
          // 编辑器不自动被内容撑高
          autoHeightEnabled: false,
          // 初始容器高度
          initialFrameHeight: 240,
          // 初始容器宽度
          initialFrameWidth: '100%',
          // 上传文件接口(这个地址是我为了方便各位体验文件上传功能搭建的临时接口,请勿在生产环境使用!!!)
          serverUrl: 'http://35.201.165.105:8000/controller.php',
          // UEditor 资源文件的存放路径,如果你使用的是 vue-cli 生成的项目,通常不需要设置该选项,vue-ueditor-wrap 会自动处理常见的情况,如果需要特殊配置,参考下方的常见问题2
          UEDITOR_HOME_URL: '/static/UEditor/'
        }
      }
    }

Advanced

  1. 如何获取 UEditor 实例?

    <vue-ueditor-wrap @ready="ready"></vue-ueditor-wrap>
    methods: {
      ready (editorInstance) {
        console.log(`编辑器实例${editorInstance.key}: `, editorInstance)
      }
    }
  2. 设置是否在组件的 beforeDestroy 钩子里销毁 UEditor 实例

    <vue-ueditor-wrap :destroy="true"></vue-ueditor-wrap>
  3. 选取 v-model 的实现方式。双向绑定的实现依赖对编辑器内容变化的监听,由于监听方式的不同,会带来监听效果的差异性,你可以自行选择,但建议使用开箱即用的默认值。

    <vue-ueditor-wrap mode="listener"></vue-ueditor-wrap>

    可选值:observerlistener

    默认值:observer

    参数说明:

    1. observer 模式借助 MutationObserver API。优点在于监听的准确性,缺点在于它会带来一点额外的性能开销。你可以通过 observerDebounceTime 属性设置触发间隔,还可以通过 observerOptions 属性有选择的设置 MutationObserver 的监听行为。该 API 只兼容到 IE11+,但 vue-ueditor-wrap 会在不支持的浏览器中自动启用 listener 模式。

      <vue-ueditor-wrap
        mode="observer"
        :observerDebounceTime="100"
        :observerOptions="{ attributes: true, characterData: true, childList: true, subtree: true }"
        >
      </vue-ueditor-wrap>
    2. listener 模式借助 UEditor 的 contentChange 事件,优点在于依赖官方提供的事件 API,无需额外的性能消耗,兼容性更好,但缺点在于监听的准确性并不高,存在如下方 [常见问题 5] 中的提到的 BUG。

  4. 是否支持 Vue SSR

    2.4.0 版本开始支持服务端渲染!本组件提供对 Nuxt 项目开箱即用的支持。但如果你是自己搭建的 Vue SSR 项目,你可能需要自行区分服务端和客户端环境并结合 forceInit 属性强制初始化编辑器,但大概率你用不到该属性,即使是自己搭建的 SSR 项目,更多问题欢迎提交 ISSUE。

  5. 如何进行二次开发(添加自定义按钮、弹窗等)?

    本组件提供了 beforeInit 钩子,它会在 UEditor 的 scripts 加载完毕之后、编辑器初始化之前触发,你可以在此时机,通过操作 window.UE 对象,来进行诸如添加自定义按钮、弹窗等的二次开发。beforeInit 的触发函数以 编辑器 id 和 配置参数 作为入参。下面提供了一个简单的自定义按钮和自定义弹窗的示例,DEMO 仓库中也提供了自定义“表格居中”按钮的示例,如果有更多二次开发的需求,你可以参考官方 API 或者 UEditor 源码 中的示例。

    自定义按钮 Demo
    <vue-ueditor-wrap v-model="msg" @beforeInit="addCustomButtom"></vue-ueditor-wrap>
    addCustomButtom (editorId) {
      window.UE.registerUI('test-button', function (editor, uiName) {
        // 注册按钮执行时的 command 命令,使用命令默认就会带有回退操作
        editor.registerCommand(uiName, {
          execCommand: function () {
            editor.execCommand('inserthtml', `<span>这是一段由自定义按钮添加的文字</span>`)
          }
        })
    
        // 创建一个 button
        var btn = new window.UE.ui.Button({
          // 按钮的名字
          name: uiName,
          // 提示
          title: '鼠标悬停时的提示文字',
          // 需要添加的额外样式,可指定 icon 图标,图标路径参考常见问题 2
          cssRules: "background-image: url('/test-button.png') !important;background-size: cover;",
          // 点击时执行的命令
          onclick: function () {
            // 这里可以不用执行命令,做你自己的操作也可
            editor.execCommand(uiName)
          }
        })
    
        // 当点到编辑内容上时,按钮要做的状态反射
        editor.addListener('selectionchange', function () {
          var state = editor.queryCommandState(uiName)
          if (state === -1) {
            btn.setDisabled(true)
            btn.setChecked(false)
          } else {
            btn.setDisabled(false)
            btn.setChecked(state)
          }
        })
    
        // 因为你是添加 button,所以需要返回这个 button
        return btn
      }, 0 /* 指定添加到工具栏上的哪个位置,默认时追加到最后 */, editorId /* 指定这个 UI 是哪个编辑器实例上的,默认是页面上所有的编辑器都会添加这个按钮 */)
    }
    自定义弹窗 Demo
    <vue-ueditor-wrap v-model="msg" @beforeInit="addCustomDialog"></vue-ueditor-wrap>
    addCustomDialog (editorId) {
      window.UE.registerUI('test-dialog', function (editor, uiName) {
        // 创建 dialog
        var dialog = new window.UE.ui.Dialog({
          // 指定弹出层中页面的路径,这里只能支持页面,路径参考常见问题 2
          iframeUrl: '/customizeDialogPage.html',
          // 需要指定当前的编辑器实例
          editor: editor,
          // 指定 dialog 的名字
          name: uiName,
          // dialog 的标题
          title: '这是一个自定义的 Dialog 浮层',
          // 指定 dialog 的外围样式
          cssRules: 'width:600px;height:300px;',
          // 如果给出了 buttons 就代表 dialog 有确定和取消
          buttons: [
            {
              className: 'edui-okbutton',
              label: '确定',
              onclick: function () {
                dialog.close(true)
              }
            },
            {
              className: 'edui-cancelbutton',
              label: '取消',
              onclick: function () {
                dialog.close(false)
              }
            }
          ]
        })
    
        // 参考上面的自定义按钮
        var btn = new window.UE.ui.Button({
          name: 'dialog-button',
          title: '鼠标悬停时的提示文字',
          cssRules: `background-image: url('/test-dialog.png') !important;background-size: cover;`,
          onclick: function () {
            // 渲染dialog
            dialog.render()
            dialog.open()
          }
        })
    
        return btn
      }, 0 /* 指定添加到工具栏上的那个位置,默认时追加到最后 */, editorId /* 指定这个UI是哪个编辑器实例上的,默认是页面上所有的编辑器都会添加这个按钮 */)
    }

    弹出层中的 HTML 页面 customizeDialogPage.html

    <!DOCTYPE html>
    <html>
    
    <head>
      <meta charset="UTF-8">
      <title>Title</title>
      <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
      <meta name="renderer" content="webkit">
      <!--页面中一定要引入internal.js为了能直接使用当前打开dialog的实例变量-->
      <!--internal.js默认是放到 UEditor/dialogs 目录下的-->
      <script type="text/javascript" src="./UEditor/dialogs/internal.js"></script>
    </head>
    
    <body>
      <h1>hello vue-ueditor-wrap</h1>
      <script>
        //可以直接使用以下全局变量
        //当前打开dialog的实例变量
        console.log('editor: ' + editor);
        //一些常用工具
        console.log('domUtils: ' + domUtils);
        console.log('utils: ' + utils);
        console.log('browser: ' + browser);
        dialog.onok = function() {
          editor.execCommand('inserthtml', '<span>我点击了确定</span>');
        };
        dialog.oncancel = function() {
          editor.execCommand('inserthtml', '<span>我点击了取消</span>');
        };
      </script>
    </body>
    
    </html>

Features

  1. v-model 双向数据绑定!你不需要考虑实例化,也不需要考虑何时 getContent,何时setContent,简单到像使用 input 框一样!

  2. 完全遵从官方 API,所有的配置参数和实例方法与官方完全一致。通过给 vue-ueditor-wrap 组件的 config 属性传递一个对象,你就可以得到一个完全独立配置的 UEditor 编辑器。通过监听 ready 事件你就可以得到初始化后的 UEditor 实例并执行实例上的各种方法。

  3. 自动添加依赖文件。你不需要自己在 index.htmlmain.js 里引入 UEditor 的 JS 文件。更重要的是即使你在一个页面里同时使用多个 vue-ueditor-wrap 组件,它所依赖的 JS 文件也只会加载一次。这么做的原因在于你不需要当用户一打开项目就先加载大量 UEditor 相关的资源,所有的资源文件只会在 vue-ueditor-wrap 组件第一次被激活时才加载。当然,如果你在 index.htmlmain.js 里引入了相关资源,vue-ueditor-wrap 也会准确判断,你不用担心它会重复加载。

  4. 每个 vue-ueditor-wrap 组件是完全独立的。你甚至可以在上面使用 v-for 指令一次渲染 99个 兔斯基(不要忘记添加 key 值)。

FAQ(常见问题)

  1. 是否支持 IE 等低版本浏览器?

    Vue 相同,整体支持到 IE9+👏👏👏

  2. 为什么我会看到这个报错?

    这是 UEDITOR_HOME_URL 参数配置错误导致的。在 vue cli 2.x 生成的项目中使用本组件,默认值是 '/static/UEditor/',在 vue cli 3.x 生成的项目中,默认值是 process.env.BASE_URL + 'UEditor/' 。但这并不能满足所有情况。例如你的项目不是部署在网站根目录下,如"http://www.example.com/my-app/",你可能需要设置为"/my-app/static/UEditor/"。是否使用了相对路径、路由是否使用 history 模式、服务器配置是否正确等等都有可能会产生影响。总而言之:无论本地开发和部署到服务器,你所指定的 UEditor 资源文件是需要真实存在的,vue-ueditor-wrap 也会在 JS 加载失败时通过 console 输出它试图去加载的资源文件的完整路径,你可以借此分析如何填写。当需要区分环境时,你可以通过判断 process.env.NODE_ENV 来分别设置。

  3. 我该如何上传图片和文件?为什么我会看到后台配置项返回格式出错

    上传图片、文件等功能是需要与后台配合的,而你没有给 config 属性传递正确的 serverUrl ,我提供了http://35.201.165.105:8000/controller.php 的临时接口,你可以用于测试,但切忌在生产环境使用!!! 关于如何搭建上传接口,可以参考官方文档

  4. 单图片跨域上传失败!

    UEditor 的单图上传是通过 Form 表单 + iframe 的方式实现的,但由于同源策略的限制,父页面无法访问跨域 iframe 的文档内容,所以会出现单图片跨域上传失败的问题。我通过 XHR 重构了单图上传的方式,下载最新编译的 UEditor 资源文件即可在 IE10+ 的浏览器中实现单图跨域上传了。具体细节,点此查看。当然你也可以通过配置 toolbars 参数来隐藏单图片上传按钮,并结合上面介绍的“自定义按钮”,曲线救国,以下代码仅供参考。

    var input = document.createElement('input')
    input.type = "file"
    input.style.display = 'none'
    document.body.appendChild(input)
    input.click()
    input.addEventListener('change',(e)=>{
        // 利用 AJAX 上传,上传成功之后销毁 DOM
        console.log(e.target.files)
    })
  5. 为什么我输入的"? ! $ #" 这些特殊字符,没有成功绑定?

    当你使用 listener 模式时,由于 v-model 的实现是基于对 UEditor 实例上 contentChange 事件的监听,而你输入这些特殊字符时通常是按住 shift 键的,UEditor 本身的 contentChangeshift 键按住时不会触发,你也可以尝试同时按下多个键,你会发现 contentChange 只触发一次。你可以使用 observer 模式或移步 UEditor

  6. 单图片上传后 v-model 绑定的是 loading 小图标。

    这个也是 UEditorBUG。我最新编辑的版本,修复了官方的这个 BUG,如果你使用的是官网下载的资源文件,请替换资源文件或参考 Issue1

更多问题,欢迎提交 ISSUE 或者去 聊天室 提问。但由于这是一个个人维护的项目,我平时也有自己的工作,所以并不能保证及时解决你们的所有问题,如果小伙伴们有好的建议或更炫酷的操作,也欢迎 PR,如果你觉得这个组件给你的开发带来了实实在在的方便,也非常感谢你的Star,当然还有咖啡:

代码修改请遵循指定的 ESLint 规则,PR 之前请先执行 npm run lint 进行代码风格检测,大部分语法细节可以通过 npm run fix 修正,构建之后,记得修改 package.json 里的版本号,方便我 Review 通过后麻溜溜的发布到 npm

总结

虽然这是一次很小的创新,UEditor也可能是一个过气的富文本编辑器。但是在维护这个项目以及帮助一众小伙伴解决ISSUE的过程中,我成长了很多。最令我感动的是不少小伙伴还给我邮箱发了感谢信,而且我还发现确实已经有一些人开始在项目中用了。这种被他人认可,以及帮助别人的快乐真的只有体会过的人才知道。也就在前不久,我决定开始在掘金写博客,虽然一些东西写的不那么好,或者自己认知有错误,但总有一群热心且优秀的小伙伴,会在评论区指正以及给出宝贵的意见。分享是快乐的!所以,我的这篇文章也权当抛砖引玉,如果小伙伴们有好的建议或更炫酷的操作,也欢迎PR,不过PR之前请先执行npm run lint进行代码风格检测,大部分语法细节也可以通过npm run fix修正,也要记得修改package.json的版本号version,方便我直接发布到npm。当然如果你有好用的富文本编辑器,也可以在评论区推荐。

[译] 理解 React Hooks

本周,Sophie Alpert 和我在 React Conf 上提出了 “Hooks” 提案,紧接着是 Ryan Florence 对 Hooks 的深入介绍:

我强烈推荐大家观看这个开场演讲,在这个演讲里,大家可以了解到我们尝试使用 Hooks 提案去解决的问题。不过,花费一个小时看视频也是时间上的巨大投入,所以我决定在下面分享一些关于 Hooks 的想法。

注意:Hooks 是 React 的实验性提案。你无需现在就去学习它们。另请注意,这篇文章包含我的个人意见,并不一定代表 React 团队的立场

为什么需要 Hooks?

我们知道组件和自上而下的数据流可以帮助我们将庞大的 UI 组织成小型、独立、可复用的块。但是,我们经常无法进一步拆分复杂的组件,因为逻辑是有状态的,而且无法抽象为函数或其他组件。这也就是人们有时说 React 不允许他们“分离关注点”的意思。

这些情况很常见,包括动画、表单处理、连接到外部数据源以及其他很多我们希望在组件中做的事情。当我们尝试单独使用组件来解决这些问题时,通常我们会这样收场:

  • 巨大的组件 难以重构和测试。
  • 重复的逻辑 在不同的组件和生命周期函数之间。
  • 复杂的模式 像 render props 和高阶组件。

我们认为 Hooks 是解决所有这些问题的最佳实践。Hooks 让我们将组件内部的逻辑组织成可复用的隔离单元

Hooks 在组件内部应用 React 的哲学(显式数据流和组合),而不仅仅是组件之间。这就是为什么我觉得 Hooks 天生就适用于 React 的组件模型。

不同于 render props 或高阶组件等的模式,Hooks 不会在组件树中引入不必要的嵌套。它们也没有受到 mixins 的负面影响

即使你内心一开始是抵触的(就像我刚开始一样!),我还是强烈建议你直接对这个提案进行一次尝试和探索。我想你会喜欢它的。

Hooks 会使 React 变得臃肿吗?

在我们详细介绍 Hooks 之前,你可能会担心我们通过 Hooks 只是向 React 添加了更多概念。这是一个公正的批评。我认为虽然学习它们肯定会有短期的认知成本,不过最终的结果却恰恰相反。

如果 React 社区接受 Hooks 的提案,这将减少编写 React 应用时需要考虑的概念数量。Hooks 可以使得你始终使用函数,而不必在函数、类、高阶组件和 reader 属性之间不断切换。

就部署大小而言,对 Hooks 的支持仅仅增加了 React 约 1.5kB(min + gzip)的大小。虽然不多,但由于使用 Hooks 的代码通常可以比使用类的等效代码压缩得更小,所以使用 Hooks 也可能会减少你的包大小。下面这个例子有点极端,但它有效地展示了我这么说的原因(点击查看整个帖子):

Hooks 提案不包括任何重大变化。即使你在新编写的组件中采用了 Hooks,你现有的代码仍将照常运行。事实上,这正是我们推荐的 —— 不做大的重写!在任何关键代码中采用 Hooks 都是一个好主意。与此同时,如果你能够尝试 16.7 alpha 版并在 Hooks proposalreport any bugs 向在我们提供反馈,我们将不胜感激。

究竟什么是 Hooks?

要了解 Hooks,我们需要退一步来思考代码复用。

今天,有很多方式可以在 React 应用中复用逻辑。我们可以编写一个简单的函数并调用它们来进行某些计算。我们也可以编写组件(它们本身可以是函数或类)。组件更强大,但它们必须渲染一些 UI。这使得它们不便于共享非可视逻辑。这使得我们最终不得不用到 render props 和高阶组件等复杂模式。如果只用一种简单的方式来复用代码而不是那么多,那么React会不会简单点

函数似乎是代码复用的一种完美机制。在函数之间组织逻辑仅需要最少的精力。但是,函数内无法包含 React 的本地状态。在不重构代码或不抽象出 Observables 的情况下,你也无法从类组件中抽象出“监视窗口大小并更新状态”或“随时间变化改变动画值”的行为。这两种方法都破坏了我们喜欢的 React 的简单性。

Hooks 正好解决了这个问题。 Hooks 允许你通过调用单个函数以在函数中使用 React 的特性(如状态)。React 提供了一些内置的 Hooks,它们暴露了 React 的“构建块”:状态、生命周期和上下文。

由于 Hooks 是普通的 JavaScript 函数,因此你可以将 React 提供的内置 Hooks 组合到你自己的“自定义 Hooks”中。这使你可以将复杂问题转换为一行代码,并在整个应用或 React 社区中分享它们:

注意,自定义 Hooks 从技术上讲并不是 React 的特性。编写自定义 Hooks 的可行性源自于 Hooks 的设计方式。

来点代码!

假设我们想要将订阅一个自适应当前窗口宽度的组件(例如,在有限的视图上显示不同的内容)。

现在你有几种方法可以编写这种代码。这些方法包括编写类,设置一些生命周期函数,如果要在组件之间复用,甚至可以需要提取 render props 或更高一层的组件。但我认为没有比这更好的了:

如果你看这段代码,它恰恰就是我所表达的。我们在我们的组件中使用窗口的宽度,而 React 将会在它变化是重新渲染。这就是 Hooks 的目的 —— 使组件做到真正的声明式,即使它们包含状态和副作用。

让我们来看看如何实现这个自定义 Hooks。我们使用 React 的本地状态来保存当前窗口宽度,并在窗口调整大小时使用一个副作用来设置该状态:

就像你从上面看到的那样,像 useStateuseEffect 这样作为基本构建块的 React 内置 Hooks。我们可以直接在组件中使用它们,或者我们可以将它们整合到自定义 Hooks 中,就像 useWindowWidth 那样。使用自定义 Hooks 感觉就像使用 React 的内置 API 一样得心应手。

你可以从此概述中了解有关内置 Hooks 的更多信息。

Hooks 是完全封装的 —— 你每次调用 Hooks 函数, 它都会从当前执行组件中获取到独立的本地状态。对这个特殊的例子来说并不重要(所有组件的窗口宽度是相同的!),但这正是 Hooks 如此强大的原因。它们不仅是一种共享状态的方式,更是共享状态化逻辑的方式。我们不想破坏自上而下的数据流!

每个 Hooks 都可以包含一些本地状态和副作用。你可以在不同 Hooks 之间传值,就像在通常在函数之间做的那样。Hooks 可以接受参数并返回值,因为它们就是JavaScript 函数。

这是一个实验 Hooks 的 React 动画库的例子:

在 CodeSandbox 上运行这个例子

注意,在演示代码中,这个惊人的动画是通过几个自定义 Hooks 的传值实现的。

(如果你想了解更多关于这个例子的信息, 查看此介绍。)

在 Hooks 之间传递数据的能力使得它们非常适合实现动画、数据订阅、表单管理和其他状态化的抽象。不同于 render props 和高阶组件,Hooks 不会在渲染树中创建“错误层次结构”。它们更像是一个连接到组件的“存储单元”的平面列表。没有额外的层。

类又该何去何从?

在我们看来,自定义 Hooks 是 Hooks 提案中最吸引人的部分。但是为了使自定义 Hooks 工作,React 需要为函数提供一种声明状态和副作用的办法。而这也正是像 useStateuseEffect 这样的内置 Hooks 允许我们做的事情。你可以在文档中了解它们。

事实证明,这些内置 Hooks 不仅可用于创建自定义 Hooks。它们足以用来定义组件,因为它们像 state 一样为我们提供了所有必要的特性。这就是为什么我们希望 Hooks 成为未来定义 React 组件的主要原因。

我们没有打算弃用类。在 Facebook,我们有成千上万的类组件,而且和你一样,我们无意重写它们。但是如果 React 社区接受了 Hooks,那么同时推荐两种不同的方式来编写组件是没有意义的。Hooks 可以涵盖类的所有应用场景,同时在抽象,测试和复用代码方面提供更大的灵活性。这就是为什么 Hooks 代表了我们对 React 未来的愿景。

不过 Hooks 是不是有点“魔术化”?

你可能会对Hooks 的规则感到惊讶。

虽然必须在顶层调用 Hooks 是不寻常的,但即使可以,你可能也不希望在某种条件判断中定义状态。例如,你也无法对类中定义的状态进行条件判断,而在过去四年和 React 用户的交流中,我也没有听到过对此的抱怨。

这种设计在不引入额外的语法噪音或其他坑的情况下,对自定义 Hooks 至关重要。我们知道用户一开始可能不熟悉,但我们认为这种取舍对未来是值得的。如果你不同意,我鼓励你动手去实践一下,看看这是否会改变你的感受。

我们已经在生产环境下使用 Hooks 一个月了,以观察工程师们是否对这些规则感到困惑。我们发现实际情况是人们会在几个小时内习惯它们。就个人而言,我承认这些规则起初让我“感觉不对”,但我很快就克服了它。这次经历像极了我对 React 的第一印象。(你一开始就喜欢 React 吗?至少我不是一开始就喜欢,直到更多次尝试后才改变看法。)

记住,在 Hooks 的实现中也没有什么“魔术”。就像 Jamie 指出的那样,它像极了这个:

我们为每个组件保留了一个 Hooks 列表,并在每次 Hooks 被调用时移动到列表中的下一项。得意于 Hooks 的规则,它们的顺序在每次渲染中都是相同的,因此我们可以为每次调用提供正确的组件状态。要知道 React 不需要做任何特殊处理就能知道哪个组件正在渲染 —— 调用你的组件的正是 React。

也许你在想 React 在哪里保存了 Hooks 的状态。答案就是,它保存在和 React 为类保持状态相同位置。无论你如何定义组件,React 都有一个内部的更新队列,它是任何状态的真实来源。

Hooks 不依赖于现代 JavaScript 库中常见的代理或 getter。按理说,Hooks 比一些解决类似问题的流行方法平常。我想说 Hooks 就像调用 array.push 和 array.pop 一样普通(一样的取决于调用顺序!)。

Hooks 的设计与 React 无关。事实上,在提案发布后的前几天,不同的人提出了针对 Vue,Web Components 甚至原生 JavaScript 函数的相同 Hooks API 的实验性实现。

最后,如果你是一个纯函数编程主义者并且对 React 依赖可突变状态的实现细节感到不安,你会欣喜的发现完成 Hooks 可以以函数式编程的方式实现(如果 JavaScript 支持它们)。当然,React 一直依赖于内部的可突变状态 —— 正因如此不必那样做。

无论你是从一个更务实还是教条的角度来考虑(或者你两者兼有),我希望至少有一个立场是有意义的。最重要的是,我认为 Hooks 让我们用更少的精力去构建组件,并提供更好的用户体验。这就正是我个人对 Hooks 感到兴奋的地方。

传播正能量,而不是炒作

如果 Hooks 对你还没有什么吸引力,我完全可以理解。我仍然希望你能在一个很小的项目上尝试一下,看看是否会改变你的看法。无论你是遇到需要 Hooks 来解决的问题,还是说你有不同的解决方案,欢迎通过 RFC 告诉我们!

如果我你感到兴奋,或者说有那么点好奇,那就太好了!我只有一个问题要问。现在有很多人正在学习 React,如果我们匆匆忙忙的编写教程,并把仅仅才出现几天的功能宣称为最佳实践,他们会感到困惑。即使对我们在 React 团队的人来说,关于 Hooks 的一些事情还不是很清楚。

如果你在 Hooks 不稳定期间开发了任何有关 Hooks 的内容,请突出提示它们是一个实验性提案,并包含指向官方文档的链接。我们会在提案发生任何改变时及时更新它。我们也花了相当多的精力来完善它,所以很多问题已在那里得到了解决。

当你和其他不像你那么兴奋的人交流时,请保持礼貌。如果你发现别人对此有误解,如果对方乐意的话你可以和他分享更多信息。但任何改变都是可怕的,作为一个社区,我们应该尽力帮助人们,而不是疏远他们。如果我(或 React 团队中的任何其他人)未遵循此建议,请致电我们!

更进一步

查看 Hooks 提案的文档以了解更多信息:

Hooks 仍然处于早期阶段,但我们很乐意能听到你们的反馈。你可以直接去 RFC,与此同时,我们也会尽量及时回复 Twitter 上的对话。

如果有不清楚的地方,请告诉我,我很乐意为你答疑解惑。感谢你的阅读!

Vitra — Portemanteau Hang it all

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

JS数组循环的性能和效率分析(for、while、forEach、map、for of)

前言

前端开发中经常涉及到数组的相关操作:去重、过滤、求和、数据二次处理等等。都需要我们对数组进行循环。为了满足各种需求,JS除了提供最简单的for循环,在ES6和后续版本中也新增的诸如:map、filter、some、reduce等实用的方法。因为各个方法作用不同,简单的对所有涉及到循环的方法进行执行速度比较,是不公平的,也是毫无意义的。那么我们就针对最单纯的以取值为目的的循环进行一次性能和效率测试,用肉眼可见的方式,对JS中常见的这些数组循环方式进行一次探讨。

从最简单的for循环说起

for循环常见的四种写法,不啰嗦,直接上代码

const persons = ['郑昊川', '钟忠', '高晓波', '韦贵铁', '杨俊', '宋灿']
// 方法一
for (let i = 0; i < persons.length; i++) {
  console.log(persons[i])
}
// 方法二
for (let i = 0, len = persons.length; i < len; i++) {
  console.log(persons[i])
}
// 方法三
for (let i = 0, person; person = persons[i]; i++) {
  console.log(person)
}
// 方法四
for (let i = persons.length; i--;) {
  console.log(persons[i])
}
  1. 第一种方法是最常见的方式,不解释。

  2. 第二种方法是将persons.length缓存到变量len中,这样每次循环时就不会再读取数组的长度。

  3. 第三种方式是将取值与判断合并,通过不停的枚举每一项来循环,直到枚举到空值则循环结束。执行顺序是:

    • 第一步:先声明索引i = 0和变量person
    • 第二步:取出数组的第ipersons[i]赋值给变量person并判断是否为Truthy
    • 第三步:执行循环体,打印person
    • 第四步:i++

      当第二步中person的值不再是Truthy时,循环结束。方法三甚至可以这样写

      for (let i = 0, person; person = persons[i++];) {
        console.log(person)
      }
  4. 第四种方法是倒序循环。执行的顺序是:

    • 第一步:获取数组长度,赋值给变量i
    • 第二步:判断i是否大于0并执行i--
    • 第三步:执行循环体,打印persons[i],此时的i已经-1

      从后向前,直到i === 0为止。这种方式不仅去除了每次循环中读取数组长度的操作,而且只创建了一个变量i

四种for循环方式在数组浅拷贝中的性能和速度测试

先造一个足够长的数组作为要拷贝的目标(如果i值过大,到亿级左右,可能会抛出JS堆栈跟踪的报错)

const ARR_SIZE = 6666666
const hugeArr = new Array(ARR_SIZE).fill(1)

然后分别用四种循环方式,把数组中的每一项取出,并添加到一个空数组中,也就是一次数组的浅拷贝。并通过console.timeconsole.timeEnd记录每种循环方式的整体执行时间。通过process.memoryUsage()比对执行前后内存中已用到的堆的差值。

/* node环境下记录方法执行前后内存中已用到的堆的差值 */
function heapRecord(fun) {
  if (process) {
    const startHeap = process.memoryUsage().heapUsed
    fun()
    const endHeap = process.memoryUsage().heapUsed
    const heapDiff = endHeap - startHeap
    console.log('已用到的堆的差值: ', heapDiff)
  } else {
    fun()
  }
}
// 方法一,普通for循环
function method1() {
  var arrCopy = []
  console.time('method1')
  for (let i = 0; i < hugeArr.length; i++) {
    arrCopy.push(hugeArr[i])
  }
  console.timeEnd('method1')
}
// 方法二,缓存长度
function method2() {
  var arrCopy = []
  console.time('method2')
  for (let i = 0, len = hugeArr.length; i < len; i++) {
    arrCopy.push(hugeArr[i])
  }
  console.timeEnd('method2')
}
// 方法三,取值和判断合并
function method3() {
  var arrCopy = []
  console.time('method3')
  for (let i = 0, item; item = hugeArr[i]; i++) {
    arrCopy.push(item)
  }
  console.timeEnd('method3')
}
// 方法四,i--与判断合并,倒序迭代
function method4() {
  var arrCopy = []
  console.time('method4')
  for (let i = hugeArr.length; i--;) {
    arrCopy.push(hugeArr[i])
  }
  console.timeEnd('method4')
}

分别调用上述方法,每个方法重复执行12次,去除一个最大值和一个最小值,求平均值(四舍五入),最终每个方法执行时间的结果如下表(测试机器:MacBook Pro (15-inch, 2017) 处理器:2.8 GHz Intel Core i7 内存:16 GB 2133 MHz LPDDR3 执行环境:node v10.8.0):

x 方法一 方法二 方法三 方法四
第一次 152.201ms 156.990ms 152.668ms 152.684ms
第二次 150.047ms 159.166ms 159.333ms 152.455ms
第三次 155.390ms 151.823ms 159.365ms 149.809ms
第四次 153.195ms 155.994ms 155.325ms 150.562ms
第五次 151.823ms 154.689ms 156.483ms 148.067ms
第六次 152.715ms 154.677ms 153.135ms 150.787ms
第七次 152.084ms 152.587ms 157.458ms 152.572ms
第八次 152.509ms 153.781ms 153.277ms 152.263ms
第九次 154.363ms 156.497ms 151.002ms 154.310ms
第十次 153.784ms 155.612ms 161.767ms 153.487ms
平均耗时 152.811ms 155.182ms 155.981ms 151.700ms
用栈差值 238511136Byte 238511352Byte 238512048Byte 238511312Byte
意不意外?惊不惊喜?想象之中至少方法二肯定比方法一更快的!但事实并非如此,不相信眼前事实的我又测试了很多次,包括改变被拷贝的数组的长度,长度从百级到千万级。最后发现:在node下执行完成同一个数组的浅拷贝任务,耗时方面四种方法的差距微乎其微,有时候排序甚至略有波动。
内存占用方面:方法一 < 方法四 < 方法二 < 方法三,但差距也很小。

v8引擎新版本针对对象取值等操作进行了最大限度的性能优化,所以方法二中缓存数组的长度到变量len中,并不会有太明显的提升。即使是百万级的数据,四种for循环的耗时差距也只是毫秒级,内存占用上四种for循环方式也都非常接近。在此感谢YaHuiLiang七秒先生戈寻谋doxP超级大柱子的帮助和指正,如果大佬们有更好的见解也欢迎评论留言。

同样是v8引擎谷歌浏览器,测试发现四种方法也都非常接近。

谷歌

但是在火狐浏览器中的测试结果:方法二 ≈ 方法三 ≈ 方法四 < 方法一,表明二三四这三种写法都可以在一定程度上优化for循环

火狐

而在safari浏览器下方法四 < 方法一 ≈ 方法二 ≈ 方法三,只有方法四体现出了小幅度的优化效果。

safari

小结

考虑到在不同环境或浏览器下的性能和效率:

推荐第四种i--倒序循环的方式。在奇舞团的这篇文章——嗨,送你一张Web性能优化地图2.3 流程控制小节里也略有提及这种方式。

不推荐第三种方式。主要是因为当数组里存在非Truthy的值时,比如0'',会导致循环直接结束。

while循环以及ES6+的新语法forEachmapfor of,会更快吗?

不啰嗦,实践是检验真理的唯一标准

// 方法五,while
function method5() {
  var arrCopy = []
  console.time('method5')
  let i = 0
  while (i < hugeArr.length) {
    arrCopy.push(hugeArr[i++])
  }
  console.timeEnd('method5')
}
// 方法六,forEach
function method6() {
  var arrCopy = []
  console.time('method6')
  hugeArr.forEach((item) => {
    arrCopy.push(item)
  })
  console.timeEnd('method6')
}
// 方法七,map
function method7() {
  var arrCopy = []
  console.time('method7')
  arrCopy = hugeArr.map(item => item)
  console.timeEnd('method7')
}
// 方法八,for of
function method8() {
  var arrCopy = []
  console.time('method8')
  for (let item of hugeArr) {
    arrCopy.push(item)
  }
  console.timeEnd('method8')
}

测试方法同上,测试结果:

x 方法五 方法六 方法七 方法八
第一次 151.380ms 221.332ms 875.402ms 240.411ms
第二次 152.031ms 223.436ms 877.112ms 237.208ms
第三次 150.442ms 221.853ms 876.829ms 253.744ms
第四次 151.319ms 222.672ms 875.270ms 243.165ms
第五次 150.142ms 222.953ms 877.940ms 237.825ms
第六次 155.226ms 225.441ms 879.223ms 240.648ms
第七次 151.254ms 219.965ms 883.324ms 238.197ms
第八次 151.632ms 218.274ms 878.331ms 240.940ms
第九次 151.412ms 223.189ms 873.318ms 256.644ms
第十次 155.563ms 220.595ms 881.203ms 234.534ms
平均耗时 152.040ms 221.971ms 877.795ms 242.332ms
用栈差值 238511400Byte 238511352Byte 53887824Byte 191345296Byte

node下,由上面的数据可以很明显的看出,forEachmapfor of 这些ES6+的语法并没有传统的for循环或者while循环快,特别是map方法。但是由于map有返回值,无需额外调用新数组的push方法,所以在执行浅拷贝任务上,内存占用很低。而for of语法在内存占用上也有一定的优势。顺便提一下:for循环 while循环 for of 循环是可以通过break关键字跳出的,而forEach map这种循环是无法跳出的。

但是随着执行环境和浏览器的不同,这些语法在执行速度上也会出现偏差甚至反转的情况,直接看图:

谷歌浏览器

谷歌

火狐浏览器

火狐

safari浏览器下

safari

可以看出:

  1. 谷歌浏览器中ES6+的循环语法会普遍比传统的循环语法慢,但是火狐和safari中情况却几乎相反。
  2. 谷歌浏览器的各种循环语法的执行耗时上差距并不大。但map特殊,速度明显比其他几种语法慢,而在火狐和safari中却出现了反转,map反而比较快!
  3. 苹果大法好

总结

之前有听到过诸如“缓存数组长度可以提高循环效率”或者“ES6的循环语法更高效”的说法。说者无心,听者有意,事实究竟如何,实践出真知。抛开业务场景和使用便利性,单纯谈性能和效率是没有意义的。 ES6新增的诸多数组的方法确实极大的方便了前端开发,使得以往复杂或者冗长的代码,可以变得易读而且精炼,而好的for循环写法,在大数据量的情况下,确实也有着更好的兼容和多环境运行表现。当然本文的讨论也只是基于观察的一种总结,并没有深入底层。而随着浏览器的更新,这些方法的孰优孰劣也可能成为玄学。目前发现在Chrome Canary 70.0.3513.0 for of 会明显比Chrome 68.0.3440.84快。如果你有更深入的见解或者文章,也不妨在评论区分享,小弟的这篇文章也权当抛砖引玉。如果你对数组的其他循环方法的性能和效率也感兴趣,不妨自己动手试一试,也欢迎评论交流。

本文的测试环境:node v10.8.0Chrome 68.0.3440.84Safari 11.1.2 (13605.3.8)Firefox 60.0

一架小飞机~~

在上一遍文章 移动端调试痛点?——送你五款前端开发利器 中,我介绍了五种常用的移动端调试方法。但由于第五种方法,需要爬梯子,所以给很多小伙伴的实践带来了不便。在这边文章中我将详细介绍如何一步一步搭一个梯子,但是由于某种不便明说的原因,可能这篇文章会在稍后删除,如果无法自己搭建或者需要帮助的小伙伴可以邮件[email protected]联系我,不要评论也不要点喜欢!毕竟我不想上首页。收到邮箱后,我会在工作之余尽快处理。最重要的一点是:希望大家把它当做一个学习交流的工具,如果看到不该看的东西,也要独立思考、正确看待、理性分析。

一、准备一个 阿里云 账号

二、新建安全组(后面有用,提前建好)

进入 管理控制台,点击左侧安全组

会出现一个列表页,按下图顺序操作:

一般会弹出提示框,点击立即设置规则

如果没有弹出,在列表中也可以点击配置规则进入

比对下图,添加新的安全组规则,授权对象填 0.0.0.0/0

至此安全组就新建完毕了,这一步很重要,请务必认证检查和上图是否一致。

三、购买云服务器

访问 阿里云首页,购买入口如下图:

Step By Step! 按照如下方式选择,是我认为比较经济的方式:

1. 基础配置

说明:计费建议选择“按量付费”,如果不想用了或者之后的梯子搭建出错了,可以随时释放(删除)掉。地域一定要选内地以外的,建议选择香港,如果只是搭梯子,最低配置的实例就够了。

全部选择完毕,点击“下一步”,如果你阿里云账号没钱,需要先充值100元。

2. 网络和安全组

3. 系统配置

为了方便后面演示,就直接使用"自定义密码"的方式了(务必牢记,后面要用)。下一步的“分组设置”如果你不需要就可以直接点击确认订单了。

如果你需要从Mac的终端或者使用PuTTYSSH工具远程连接实例,可以选择密钥对。

附上确认订单,你可以比对一下。确认无误后,点击“创建实例”。

创建成功后,去“管理控制台”

四、连接实例

找到你刚才创建的实例,点击“远程连接”

首次进入,会出现6位数字的“远程连接密码”,务必牢记!

输入刚才的6位密码

连接成功就会出现如下的界面,输入root回车,然后输入你创建实例中第三步设置的密码。

如果验证通过,就会出现类似如下的欢迎提示:

五、搭建小飞机

  1. 把下面这段脚本粘贴到下图所示位置,点击确定并回车。
wget --no-check-certificate -O shadowsocks-all.sh https://raw.githubusercontent.com/teddysun/shadowsocks_install/master/shadowsocks-all.sh
chmod +x shadowsocks-all.sh
./shadowsocks-all.sh 2>&1 | tee shadowsocks-all.log

  1. 参照下图填写配置,全部配置设置完毕之后,回车,脚本会自动安装,大概需要5分钟。

  1. 安装成功后会出现如图的提示内容(务必牢记):

至此,所有服务端的配置都已完成!!!

六、安装客户端

Mac客户端windows客户端安卓客户端IOS客户端在美区的 App Store 里可以使用下图的这个软件

如果没有美区的Apple ID,下图的这个**区的付费软件应该也可以,提前声明,我没试过,我自己用是上图的客户端!!!

以Mac客户端为例,安装完成之后,打开软件,添加服务器设置

把刚才服务器上配置的相关项,按下图填入相应位置,点击确认即可。

如果没有意外,你此时应该可以点开 这里 了。大功告成!!!访问 https://www.ipip.net/ ,你此时的IP,应该还是内地的IP

那是因为设置的是PAC自动模式,如果你切换到全局模式,刷新页面,你的IP就变成刚才阿里云上的香港实例的公网IP了!

image

不过,即使你设置为全局模式,你的终端依旧是内地的IP。按下图操作,你的终端的IP也会变成香港的,安装一下npm 或者 从 GitHub 克隆提交项目,体验一下坐飞机的感觉吧!关闭之后再打开就会失效,需要重新复制执行。

其他客户端的配置都大同小异,就不一一介绍了。服务器选购腾讯云、Vultr、谷歌云等服务商的非内地服务器均可。再次强调希望大家把它当做一个学习交流的工具,如果看到不该看的东西,也要独立思考、正确看待、理性分析。如果有问题,可以邮箱[email protected]联系我,请不要收藏或者评论。

二维码还是要贴的,写的这么仔细,万一有人请我喝咖啡呢?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.