石坚

石坚 查看完整档案

南京编辑华东师范大学  |  地理信息系统 编辑今日头条  |  web前端 编辑 stoneyallen.js.org 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

石坚 收藏了文章 · 10月22日

Web端裁剪图片方法

由于在Web端,JavaScript不能直接处理本地文件,因此可以在后台裁剪图片,或者利用html5的canvas来处理。

方法1:传送到后台剪切

步骤1:上传图片到后台,向前端返回图片URL

利用input标签,将文件发送到后台。

    <input id="image" type="file" name="image" />

可以使用jQuery中的ajaxFileUpload方法

     $.ajaxFileUpload(
        {
            url: 'live/apply/uploadImage', //用于文件上传的服务器端请求地址
            type:'post',
            secureuri: false, //一般设置为false
            fileElementId: image, //文件上传空间的id属性  
            dataType: 'json', //返回值类型 一般设置为json
            data:data, //可以传递图片属性及其他数据
            success: function (data, status)  //服务器成功响应处理函数
                {
                    //上传传成功处理
                },
            error: function (data, status, e)//服务器响应失败处理函数
                 {
                    //上传失败处理
                 }

步骤2: 进行裁剪,获取图片的坐标及长宽等值,传回后台

这里一般是利用一个移动的div来获取剪截的动画效果,目前有多种jquery插件可以使用,本文使用的是Jcrop插件,比较简单方便。

    $("#myPhoto").Jcrop({
        onChange:showPreview,
        onSelect:showPreview,
        aspectRatio:1
    });    
    function showPreview(coords){
        if(parseInt(coords.w){
        //计算预览区域图片缩放的比例,通过计算显示区域的宽度(与高度)与剪裁的宽度(与高度)之比得到
            var rx = $("#preview_box").width() / coords.w; 
            var ry = $("#preview_box").height() / coords.h;
            //通过比例值控制图片的样式与显示
            $("#crop_preview").css({
                width:Math.round(rx * $("#myPhoto").width()) + "px",    //预览图片宽度为计算比例值与原图片宽度的乘积
                height:Math.round(rx * $("#myPhoto").height()) + "px",    //预览图片高度为计算比例值与原图片高度的乘积
                marginLeft:"-" + Math.round(rx * coords.x) + "px",
                marginTop:"-" + Math.round(ry * coords.y) + "px"
            });
            
            $("#X1").val(coords.x);
            $("#Y1").val(coords.y);
            $("#X2").val(coords.x2);
            $("#Y2").val(coords.y2);
            $("#W").val(coords.w);
            $("#H").val(coords.h);
        }
    }
});

根据上述过程,可以将获取到的剪截横纵坐标和长宽数据发送到后台。

步骤3:后台裁剪图片

以java代码为例

    /*
     * 图片裁剪通用接口
     * src:图片位置,dest:图片保存位置
     * 若要覆盖原图片,只需src == dest即可
     */
    public static void cutImage(String src,String dest,int x,int y,int w,int h) throws IOException{ 
        
           File srcImg =new File(src);
           //获取后缀名
           String suffix = srcImg.getName().substring(srcImg.getName().lastIndexOf(".") + 1);
           //根据不同的后缀获取图片读取器
           Iterator iterator = ImageIO.getImageReadersBySuffix(suffix); 
           ImageReader reader = (ImageReader)iterator.next(); 
           
           InputStream in=new FileInputStream(src);
           ImageInputStream iis = ImageIO.createImageInputStream(in); 
           
           reader.setInput(iis, true); 
           ImageReadParam param = reader.getDefaultReadParam(); 
          
           //设置裁剪位置
           Rectangle rect = new Rectangle(x, y, w,h);  
           param.setSourceRegion(rect); 
           
           //保存裁剪后的图片
           BufferedImage bi = reader.read(0,param);   
           ImageIO.write(bi, suffix, new File(dest));       
    } 

方法2:Html5的canvas技术

这个需要浏览器支持以下几个点,并且兼容性还没有进行测试:

  • File API

  • Blob

  • canvas

步骤1:读取文件

如方法1一样,需要用input标签来获取file,但是JavaScript不能直接操作文件,因此需要File API来处理。

$('input[type=file]').change(function(){
    var file=this.files[0];
    var reader=new FileReader();
    
    reader.onload=function(){
        // 通过 reader.result 来访问生成的 DataURL
        var url=reader.result;
        setImageURL(url);
    };
    
    reader.readAsDataURL(file);
});

var image=new Image();
function setImageURL(url){
    image.data-original=url;
}

步骤2:获取裁剪坐标

参照方法1中的步骤2

步骤3:利用canvas重绘图片

首先要设置剪截后的图片大小相等的canvas。

// 以下四个参数由步骤2获得
var x,  y, width, height;

var canvas=$('<canvas width="'+width+'" height="'+height+'"></canvas>')[0],
ctx=canvas.getContext('2d');

ctx.drawImage(image,x,y,width,height,0,0,width,height);//重绘
$(document.body).append(canvas);//添加到文档中可以查看效果

步骤4:保存图片

我们要获取 canvas 中图片的信息,需要用 toDataURL 转换成上面用到的 DataURL 。 然后取出其中 base64 信息,再用 window.atob 转换成由二进制字符串。但 window.atob 转换后的结果仍然是字符串,直接给 Blob 还是会出错。所以又要用 Uint8Array 转换一下。

var data=canvas.toDataURL();

// dataURL 的格式为 “data:image/png;base64,****”,逗号之前都是一些说明性的文字,我们只需要逗号之后的就行了
data=data.split(',')[1];
data=window.atob(data);
var ia = new Uint8Array(data.length);
for (var i = 0; i < data.length; i++) {
    ia[i] = data.charCodeAt(i);
};

// canvas.toDataURL 返回的默认格式就是 image/png
var blob=new Blob([ia], {type:"image/png"});

步骤5:将blob数据发送至后台

在后台可以将Blob格式的数据转换成image保存。

查看原文

石坚 收藏了文章 · 10月21日

鼠标垫上的Git知识库

image.png

前言

Hello,大家好,我是CrazyCodes,之前在极客时间上买过一个git鼠标垫,感觉其知识点展示的非常干练,现手敲分享给大家,文末有购买链接。

Git最小配置

某账号下所有的Git仓库都有效

git config --global user.name '您的名称'
git config --global user.email '您的Email'

只对当前Git仓库有效

git config --local user.name '您的名称'
git config --local user.email '您的Email'

查看Git的配置

查看global类型的配置项

git config --global --list

查看只作用于当前仓库的配置项

git config --local --list

清除Git的配置

清除global类型的配置项

git config --unset --global 某个配置项

清除某个仓库的配置项

git config --unset --local 某个配置项

本地基本操作

查看变更情况

git status

查看当前工作在哪个分支上

git branch -v

切换到指定分支

git checkout 指定分支

把当前目录及其目录下所有变更都加入到暂存区

git add .

把仓库内所有变更都加入暂存区

git add -A

把指定文件添加到暂存区

git add 文件1 文件2 文件3

创建正式的commit

git commit 

比较某文件工作区和暂存区的差异

git diff 某文件

比较某文件暂存区和HEAD的差异

git diff HEAD 某文件

比较工作区和暂存区的所有差异

git diff --cached

把工作区指定文件恢复成和暂存区一样

git checkout 文件1 文件2 文件3

把暂存区指定文件恢复成和HEAD一样

git reset 文件1 文件2 文件3

把暂存区和工作区所有文件恢复成和HEAD一样

git reset --hard

用difftool比较任意两个commit的差异

git difftoll 提交A 提交B

查看哪些文件没被Git管控

git is-files --others

加塞临时任务的处理

把未处理完的变更先保存到stash中

git stash

临时任务处理完后继续之前未完的工作

git stash pop
或者
git stash apply

pop不保留stash,apply保留stash

查看所有的stash

git stash list

取回某次stash的变更

git stash pop stash@{数字n}

修改个人分支的历史

修改最后一次commit

  1. 在工作区修改文件
  2. git add .
  3. git commit --amend

修改中间的commit(代号x)

  1. git rebase -i X前面一个commit的id
  2. 在工作区修改文件
  3. git add .
  4. git rebase --continue

后续可能需要处理冲突,直到rebase结束

查看变更历史

当前分支各个commit用一行显示

git log --oneline

显示就近的n个commit

git log -n

用图示显示所有分支的历史

git log --oneline --graph --all

查看涉及到某文件变更的所有commit

git log 某文件

某文件各行最后修改对应的commit 以及作者

git blame 某文件

分支与标签

基于当前分支创建新分支

git branch 新分支

基于指定分支创建新分支

git branch 新分支 已有分支

基于某个commit创建分支

git branch 新分支 某个commit的id

创建分支并切换到该分支

git checkout -b 新分支

列出本地分支

git branch -v

列出本地和远端分支

git branch -av

列出远端所有分支

git branch -rv

列出名称符合某样式的远端分支

git branch -rv -l '某样式'

安全删除本地某分支

git branch -d 拟删除分支

强行删除本地某分支

git branch -D 拟删除分支

删除已合并到master分支的所有本地分支

git branch --merged master | grep -v '^\*\| master' | xargs -n 1 git branch -d

删除远端origin 已不存在的所有本地分支

git remote prune origin

给commit打上标签

git tag 标签名 commit的id

两分支之间的集成

把A分支合入到当前分支,且为merge创建commit

git merge A分支

把A分支合入到B分支,且为merge创建commet

git merge A分支 B分支

把当前分支基于B分支做rebase,以便把B分支合入到当前分支

git rebase B分支

把A分支基于B分支做rebase,以便把B分支合入到A分支

git rebase B分支 A分支

用mergetool解决冲突

git mergetool

和远端的交互

列出所有remote

git remote -v

增加remote

git remote add url地址

删除remote

git remote remove remote的名称

改变remote的name

git remote rename 旧名称 新名称

把远端所有分支和标签的变更都拉到本地

git fetch remote

把远端分支的变更拉到本地,且merge到本地分支

git pull remote 名称 分支名

把本地分支push到远端

git push remote 名称 分支名

删除远端分支

git push remote --delete 远端分支名
或者
git push remote 远端分支名

向远端提交指定标签

git push remote 标签名

向远端提交所有标签

git push remote --tags

致谢

感谢你看到这里,希望本篇文章可以帮到你,谢谢。

鼠标垫内容作者:苏玲 购买链接

查看原文

石坚 关注了用户 · 10月14日

民工哥 @jishuroad

民工哥,10多年职场老司机的经验分享,坚持自学一路从技术小白成长为互联网企业信息技术部门的负责人。

我的新书:《Linux系统运维指南》

微信公众号:民工哥技术之路

民工哥:知乎专栏

欢迎关注,我们一同交流,相互学习,共同成长!!

关注 2688

石坚 发布了文章 · 3月29日

React suspense详解

WHY REACT JS IS HERE TO STAY? - Webrexstudio

React.suspense是大家用的比较少的功能,它早在2018年的16.6.0版本中就已发布。它的相关用法有些已经比较成熟,有的相对不太稳定,甚至经历了重命名、删除。

下面一起来了解下它的主要用法、场景。

1.suspense配合lazy实现code spliting

import是webpack中的一种code spliting的用法,但是import的文件返回的是一个promise,必须封装之后才能使用,例如react-loadable的封装方法

function Loadable(opts) {
  const { loading: LoadingComponent, loader } = opts
  return class LoadableComponent extends React.Component {
    constructor(props) {
      super(props)
      this.state = {
        loading: true, // 是否加载中
        loaded: null  // 待加载的模块
      }
    }
    componentDidMount() {
      loader()
        .then((loaded) => {
          this.setState({
            loading: false,
            loaded
          })
        })
        .catch(() => {})
    }

    render() {
      const { loading, loaded } = this.state
      if (loading) {
        return <LoadingComponent />
      } else if (loaded) {
        // 默认加载default组件
        const LoadedComponent = loaded.__esModule ? loaded.default : loaded;
        return <LoadedComponent {...this.props}/>
      } else {
        return null;
      }
    }
  }
}

在promise返回后更新组件,如果使用suspense改写react-loadable,将会更加优雅

const ProfilePage = React.lazy(() =>  import('./ProfilePage'));


<Suspense fallback={<Spinner />}>

  <ProfilePage />

</Suspense>

2.1 请求数据时解决loading问题

let status = "pending";
let result;
const data = new Promise(resolve => setTimeout(() => resolve("结果"), 1000));

function wrapPromise(promise) {
  let suspender = promise.then(
    r => {
      status = "success";
      result = r;
    },
    e => {
      status = "error";
      result = e;
    }
  );
  if (status === "pending") {
     throw suspender;
  } else if (status === "error") {
    throw result;
  } else if (status === "success") {
    return result;
  }
}


function App(){
    const state = wrapPromise(data);
    
  return (<div>{state}</div>);
}

function Loading(){
    return <div>..loading</div>
}

class TodoApp extends React.Component {
  
  render() {
    return (
      <React.Suspense fallback={<Loading></Loading>}> 
        <App />
      </React.Suspense>
    )
  }
}

ReactDOM.render(<TodoApp />, document.querySelector("#app"))

源码在此
上面的写法比较奇怪,在组件App中请求数据state时,一开始返回throw promise,这是为了让suspense捕捉到error,返回loading组件,以上写法跟suspense的实现方式有关

class Suspense extends React.Component { 
    state = { promise: null } 
    componentDidCatch(e) { 
        if (e instanceof Promise) { 
            this.setState(
            { promise: e }, () => { 
                e.then(() => { 
                    this.setState({ promise: null }) 
                }) 
            }) 
        } 
    } 
    render() { 
        const { fallback, children } = this.props 
        const { promise } = this.state 
        return <> 
            { promise ? fallback : children } 
        </> 
    } 
}

从suspense源码可以看出,suspense捕捉到error后,会对其监听,当返回值时将loading改为children中的组件。
但这时又会触发一次组件渲染,所以需要对请求结果缓存,最终变成上面的写法。
这里有个官方例子可供参考,传送门

2.2 使用react-cache缓存

上面的例子非常反人类,在实际项目中基本不可能这样写,配合react-cache将会优雅许多

import React, { Suspense } from "react";
import { unstable_createResource as createResource } from "react-cache";

const mockApi = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve("Hello"), 1000);
  });
};

const resource = createResource(mockApi);

const Greeting = () => {
  const result = resource.read();

  return <div>{result} world</div>;
};

const SuspenseDemo = () => {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Greeting />
    </Suspense>
  );
};

export default SuspenseDemo;
react-cache官方目前不推荐使用在线上项目中

3.配合ConcurrentMode解决loading的闪现问题

loading的闪现问题主要是因为api接口时间短,loading不该出现,需要对接口速度进行判断

不考虑suspense按照通常的写法,可以这么实现

const timeout = ms => new Promise((_, r) => setTimeout(r, ms));

const rq = (api, ms, resolve, reject) => async (...args) => {
  const request = api(...args);
  Promise.race([request, timeout(ms)]).then(resolve, err => {
    reject(err);
    return request.then(resolve);
  });
};

suspense为我们提供了maxDuration属性,用来控制loading的触发时间

import React from "react";
import ReactDOM from "react-dom";
const {
  unstable_ConcurrentMode: ConcurrentMode,
  Suspense,
} = React;
const { unstable_createRoot: createRoot } = ReactDOM;

let status = "pending";
let result;
const data = new Promise(resolve => setTimeout(() => resolve("结果"), 3000));

function wrapPromise(promise) {
  let suspender = promise.then(
    r => {
      status = "success";
      result = r;
    },
    e => {
      status = "error";
      result = e;
    }
  );
  if (status === "pending") {
     throw suspender;
  } else if (status === "error") {
    throw result;
  } else if (status === "success") {
    return result;
  }
}


function Test(){
    const state = wrapPromise(data);
    
  return (<div>{state}</div>);
}

function Loading(){
    return <div>..loading</div>
}

class TodoApp extends React.Component {
  
  render() {
    return (
      <Suspense fallback={<Loading></Loading>} maxDuration={500}> 
        <Test />
      </Suspense>
    )
  }
}

const rootElement = document.getElementById("root");

createRoot(rootElement).render(
  <ConcurrentMode>
    <TodoApp />
  </ConcurrentMode>
);

源码地址在此

上面例子使用的是16.8.0版本

例子中用到了unstable_ConcurrentModeunstable_createRoot语法,unstable_createRoot在16.11.0中已更名为createRoot,unstable_ConcurrentMode在16.9.0中更名为unstable_createRoot
在最新16.13.1中测试发现ReactDOM.createRoot并不存在,所以本例子只在16.8.0中测试

总结

以上就是关于suspense的所有场景,目前api善不稳定,谨慎使用

招聘

最近字节跳动前端急招,有感兴趣的请私信我,或者投递我邮箱574745389@qq.com
前端base上海、北京、南京、深圳、杭州,岗位要求可参考https://job.toutiao.com/s/7wokvh
除了前端其他岗位的也欢迎投递

查看原文

赞 0 收藏 0 评论 0

石坚 回答了问题 · 2月15日

使用.assign合成对象,属性丢失

在console打印data的时候,用JSON.parse(JSON.stringify(data))包装一下,防止被后面的代码串改

关注 2 回答 2

石坚 收藏了文章 · 2月15日

一文吃透 React SSR 服务端渲染和同构原理

推荐下我自己的小册 React SSR 服务端渲染原理解析与实践

全网最完整的 React SSR 同构技术原理解析与实践,从零开始手把手带你打造自己的同构应用开发骨架,帮助大家彻底深入理解服务端渲染及底层实现原理,学完本课程,你也可以打造自己的同构框架。


写在前面

前段时间一直在研究react ssr技术,然后写了一个完整的ssr开发骨架。今天写文,主要是把我的研究成果的精华内容整理落地,另外通过再次梳理希望发现更多优化的地方,也希望可以让更多的人少踩一些坑,让跟多的人理解和掌握这个技术。

相信看过本文(前提是能对你的胃口,也能较好的消化吸收)你一定会对 react ssr 服务端渲染技术有一个深入的理解,可以打造自己的脚手架,更可以用来改造自己的实际项目,当然这不仅限于 react ,其他框架都一样,毕竟原理都是相似的。

为什么要服务端渲染(ssr)

至于为什么要服务端渲染,我相信大家都有所闻,而且每个人都能说出几点来。

首屏等待

在 SPA 模式下,所有的数据请求和 Dom 渲染都在浏览器端完成,所以当我们第一次访问页面的时候很可能会存在“白屏”等待,而服务端渲染所有数据请求和 html内容已在服务端处理完成,浏览器收到的是完整的 html 内容,可以更快的看到渲染内容,在服务端完成数据请求肯定是要比在浏览器端效率要高的多。

没考虑SEO的感受

有些网站的流量来源主要还是靠搜索引擎,所以网站的 SEO 还是很重要的,而 SPA 模式对搜索引擎不够友好,要想彻底解决这个问题只能采用服务端直出。改变不了别人(搜索yinqing),只能改变自己。

SSR + SPA 体验升级

只实现 SSR 其实没啥意义,技术上没有任何发展和进步,否则 SPA 技术就不会出现。

但是单纯的 SPA又不够完美,所以最好的方案就是这两种体验和技术的结合,第一次访问页面是服务端渲染,基于第一次访问后续的交互就是 SPA 的效果和体验,还不影响SEO 效果,这就有点完美了。

单纯实现 ssr 很简单,毕竟这是传统技术,也不分语言,随便用 php 、jsp、asp、node 等都可以实现。

但是要实现两种技术的结合,同时可以最大限度的重用代码(同构),减少开发维护成本,那就需要采用 react 或者 vue 等前端框架相结合 node (ssr) 来实现。

本文主要说 React SSR 技术 ,当然 vue 也一样,只是技术栈不同而已。

核心原理

整体来说 react 服务端渲染原理不复杂,其中最核心的内容就是同构。

node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 props
context或者store 形式传入组件,然后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件渲染为 html字符串或者 stream 流, 在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器端可以得到数据(脱水),浏览器开始进行渲染和节点对比,然后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束。

技术点确实不少,但更多的是架构和工程层面的,需要把各个知识点进行链接和整合。

这里放一个架构图

react ssr

从 ejs 开始

实现 ssr 很简单,先看一个 node ejs的栗子。

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <title>react ssr <%= title %></title>
</head>
<body>
   <%=  data %>
</body>
</html>
 //node ssr
 const ejs = require('ejs');
 const http = require('http');

http.createServer((req, res) => {
    if (req.url === '/') {
        res.writeHead(200, {
            'Content-Type': 'text/html' 
        });
        // 渲染文件 index.ejs
        ejs.renderFile('./views/index.ejs', {
            title: 'react ssr', 
            data: '首页'}, 
            (err, data) => {
            if (err ) {
                console.log(err);
            } else {
                res.end(data);
            }
        })
    }
}).listen(8080);

jsx 到字符串

上面我们结合 ejs模板引擎 ,实现了一个服务端渲染的输出,html 和 数据直接输出到客户端。

参考以上,我们结合 react组件 来实现服务端渲染直出,使用 jsx 来代替 ejs,之前是在 html 里使用 ejs 来绑定数据,现在改写成使用jsx 来绑定数据,使用 react 内置 api 来把组件渲染为 html 字符串,其他没有差别。

为什么react 组件可以被转换为 html字符串呢?

简单的说我们写的 jsx 看上去就像在写 html(其实写的是对象) 标签,其实经过编译后都会转换成React.createElement方法,最终会被转换成一个对象(虚拟DOM),而且和平台无关,有了这个对象,想转换成什么那就看心情了。

const  React  = require('react');

const { renderToString}  = require( 'react-dom/server');

const http = require('http');

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}
 
//模拟数据的获取
const fetch = function () {
    return {
        title:'react ssr',
        data:[]
    }
}

//服务
http.createServer((req, res) => {
    if (req.url === '/') {
        res.writeHead(200, {
            'Content-Type': 'text/html'
        });

        const data = fetch();

        const html = renderToString(<Index data={data}/>);
        res.end(html);
    }
}).listen(8080);

ps:以上代码不能直接运行,需要结合babel 使用 @babel/preset-react 进行转换

 
 npx babel script.js --out-file script-compiled.js --presets=@babel/preset-react
 

引出问题

在上面非常简单的就是实现了 react ssr ,把jsx作为模板引擎,不要小看上面的一小段代码,他可以帮我们引出一系列的问题,这也是完整实现 react ssr 的基石。

  • 双端路由如何维护?

首先我们会发现我在 server 端定义了路由 '/',但是在 react SPA 模式下我们需要使用react-router来定义路由。那是不是就需要维护两套路由呢?

  • 获取数据的方法和逻辑写在哪里?

发现数据获取的fetch 写的独立的方法,和组件没有任何关联,我们更希望的是每个路由都有自己的 fetch 方法。

  • 服务端 html 节点无法重用

虽然组件在服务端得到了数据,也能渲染到浏览器内,但是当浏览器端进行组件渲染的时候直出的内容会一闪而过消失。

好了,问题有了,接下来我们就一步一步的来解决这些问题。

同构才是核心

react ssr 的核心就是同构,没有同构的 ssr 是没有意义的。

所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染是无法做到的,react 的出现打破了这个瓶颈,并且现在已经得到了比较广泛的应用。

路由同构

双端使用同一套路由规则,node server 通过req url path 进行组件的查找,得到需要渲染的组件。

//组件和路由配置 ,供双端使用 routes-config.js



class Detail extends React.Component{

    render(){
        return <div>detail</div>
    }
}

class Index extends React.Component {

    render() {
        return <div>index</div>
    }
}


const routes = [
  
            {
                path: "/",
                exact: true,
                component: Home
            },
            {
                path: '/detail', exact: true,
                component:Detail,
            },
            {
                path: '/detail/:a/:b', exact: true,
                component: Detail
            }
         
];

//导出路由表
export default routes;

//客户端 路由组件

import routes from './routes-config.js';

function App(){
    return (
        <Layout>
            <Switch>

                        {
                            routes.map((item,index)=>{
                                return <Route path={item.path} key={index} exact={item.exact} render={item.component}></Route>
                            })
                        }
            </Switch>
        </Layout>
    );
}

export default App;

node server 进行组件查找

路由匹配其实就是对 组件path 规则的匹配,如果规则不复杂可以自己写,如果情况很多种还是使用官方提供的库来完成。

matchRoutes(routes, pathname)

//引入官方库
import { matchRoutes } from "react-router-config";
import routes from './routes-config.js';

const path = req.path;

const branch = matchRoutes(routes, path);

//得到要渲染的组件
const Component = branch[0].route.component;
 

//node server 
http.createServer((req, res) => {
    
        const url = req.url;
        //简单容错,排除图片等资源文件的请求
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        const data = fetch();

        //查找组件
        const branch =  matchRoutes(routes,url);
        
        //得到组件
        const Component = branch[0].route.component;

        //将组件渲染为 html 字符串
        const html = renderToString(<Component data={data}/>);

        res.end(html);
        
 }).listen(8080);

可以看下matchRoutes方法的返回值,其中route.component 就是 要渲染的组件


[
    { 
    
    route:
        { path: '/detail', exact: true, component: [Function: Detail] },
    match:
        { path: '/detail', url: '/detail', isExact: true, params: {} } 
        
    }
   ]

react-router-config 这个库由react 官方维护,功能是实现嵌套路由的查找,代码没有多少,有兴趣可以看看。

文章走到这里,相信你已经知道了路由同构,所以上面的第一个问题 : 【双端路由如何维护?】 解决了。

数据同构(预取同构)

这里开始解决我们最开始发现的第二个问题 - 【获取数据的方法和逻辑写在哪里?】

数据预取同构,解决双端如何使用同一套数据请求方法来进行数据请求。

先说下流程,在查找到要渲染的组件后,需要预先得到此组件所需要的数据,然后将数据传递给组件后,再进行组件的渲染。

我们可以通过给组件定义静态方法来处理,组件内定义异步数据请求的方法也合情合理,同时声明为静态(static),在 server 端和组件内都也可以直接通过组件(function) 来进行访问。

比如 Index.getInitialProps


//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    //数据预取方法  静态 异步 方法
    static async  getInitialProps(opt) {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}


//node server 
http.createServer((req, res) => {
    
        const url = req.url;
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });
        
        //组件查找
        const branch =  matchRoutes(routes,url);
        
        //得到组件
        const Component = branch[0].route.component;
    
        //数据预取
        const data = Component.getInitialProps(branch[0].match.params);
      
        //传入数据,渲染组件为 html 字符串
        const html = renderToString(<Component data={data}/>);

        res.end(html);

 }).listen(8080);

另外还有在声明路由的时候把数据请求方法关联到路由中,比如定一个 loadData 方法,然后在查找到路由后就可以判断是否存在loadData这个方法。

看下参考代码


const loadBranchData = (location) => {
  const branch = matchRoutes(routes, location.pathname)

  const promises = branch.map(({ route, match }) => {
    return route.loadData
      ? route.loadData(match)
      : Promise.resolve(null)
  })

  return Promise.all(promises)
}

上面这种方式实现上没什么问题,但从职责划分的角度来说有些不够清晰,我还是比较喜欢直接通过组件来得到异步方法。

好了,到这里我们的第二个问题 - 【获取数据的方法和逻辑写在哪里?】 解决了。

渲染同构

假设我们现在基于上面已经实现的代码,同时我们也使用 webpack 进行了配置,对代码进行了转换和打包,整个服务可以跑起来。

路由能够正确匹配,数据预取正常,服务端可以直出组件的 html ,浏览器加载 js 代码正常,查看网页源代码能看到 html 内容,好像我们的整个流程已经走完。

但是当浏览器端的 js 执行完成后,发现数据重新请求了,组件的重新渲染导致页面看上去有些闪烁。

这是因为在浏览器端,双端节点对比失败,导致组件重新渲染,也就是只有当服务端和浏览器端渲染的组件具有相同的props 和 DOM 结构的时候,组件才能只渲染一次。

刚刚我们实现了双端的数据预取同构,但是数据也仅仅是服务端有,浏览器端是没有这个数据,当客户端进行首次组件渲染的时候没有初始化的数据,渲染出的节点肯定和服务端直出的节点不同,导致组件重新渲染。

数据注水

在服务端将预取的数据注入到浏览器,使浏览器端可以访问到,客户端进行渲染前将数据传入对应的组件即可,这样就保证了props的一致。

 
//node server  参考代码
http.createServer((req, res) => {
    
        const url = req.url;
        if(url.indexOf('.')>-1) { res.end(''); return false;}

        res.writeHead(200, {
            'Content-Type': 'text/html'
        });

        console.log(url);
       
        //查找组件
        const branch =  matchRoutes(routes,url);
        //得到组件
        const Component = branch[0].route.component;

        //数据预取
        const data = Component.getInitialProps(branch[0].match.params);

        //组件渲染为 html
        const html = renderToString(<Component data={data}/>);

        //数据注水
        const propsData = `<textarea style="display:none" id="krs-server-render-data-BOX">${JSON.stringify(data)}</textarea>`;

        // 通过 ejs 模板引擎将数据注入到页面
        ejs.renderFile('./index.html', {
            htmlContent: html,  
            propsData
        },  // 渲染的数据key: 对应到了ejs中的index
            (err, data) => {
                if (err) {
                    console.log(err);
                } else {
                    console.log(data);
                    res.end(data);
                }
            })

 }).listen(8080);
 
 //node ejs html
 
 <!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>

<body>
    <div id="rootEle">
        <%- htmlContent %> //组件 html内容
    </div>
    
    <%- propsData %> //组件 init  state ,现在是个字符串
</body>

</html>
</body>

需要借助 ejs 模板,将数据绑定到页面上,为了防止 XSS攻击,这里我把数据写到了 textarea 标签里。

下图中,我看着明文数据难受,对数据做了base64编码 ,用之前需要转码,看个人需要。

数据脱水

上一步数据已经注入到了浏览器端,这一步要在客户端组件渲染前先拿到数据,并且传入组件就可以了。

客户端可以直接使用id=krs-server-render-data-BOX 进行数据获取。

第一个方法简单粗暴,可直接在组件内的constructor 构造函数 内进行获取,如果怕代码重复,可以写一个高阶组件。

第二个方法可以通过 context 传递,只需要在入口处传入,在组件中声明 static contextType 即可。

我是采用context 传递,为了后面方便集成 redux 状态管理 。


// 定义 context 生产者 组件

import React,{createContext} from 'react';
import RootContext from './route-context';

export default class Index extends React.Component {
    constructor(props,context) {
        super(props);
    }

    render() {
        return <RootContext.Provider value={this.props.initialData||{}}>
            {this.props.children}
        </RootContext.Provider>
    }
}

//入口  app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../';
import Provider from './provider';


//渲染入口  接收脱水数据
function renderUI(initialData) {
    ReactDOM.hydrate(<BrowserRouter><Provider initialData={initialData}>
        <Routes />
    </Provider>
    </BrowserRouter>, document.getElementById('rootEle'), (e) => {
    });
}

//函数执行入口
function entryIndex() {
    let APP_INIT_DATA = {};
    let state = true;

    //取得数据
    let stateText = document.getElementById('krs-server-render-data-BOX');

    if (stateText) {
        APP_INIT_DATA = JSON.parse(stateText.value || '{}');
    }


    if (APP_INIT_DATA) {//客户端渲染
        
        renderUI(APP_INIT_DATA);
    }
}

//入口执行
entryIndex();

行文至此,核心的内容已经基本说完,剩下的就是组件内如何使用脱水的数据。

下面通过 context 拿到数据 , 代码仅供参考,可根据自己的需求来进行封装和调整。

import React from 'react';
import './css/index.scss';

export default class Index extends React.Component {

    constructor(props, context) {
        super(props, context);

        //将context 存储到 state 
        this.state = {
            ... context
        }

    }

    //设置此参数 才能拿到 context 数据
    static contextType = RootContext;

    //数据预取方法
    static async getInitialProps(krsOpt) {

        if (__SERVER__) {
            //如果是服务端渲染的话  可以做的处理,node 端设置的全局变量
        }

        const fetch1 = fetch.postForm('/fe_api/filed-manager/get-detail-of-type', {
            data: { ofTypeId: 4000 }
        });

        const fecth2 = fetch.postForm('/fe_api/filed-manager/get-detail-of-type', {
            data: { ofTypeId: 2000 }
        });

        const resArr = await fetch.multipleFetch(fetch1, fecth2);
        //返回所有数据
        return {
            page: {},
            fetchData: resArr
        }
    }

    componentDidMount() {
        if (!this.isSSR) { //非服务端渲染需要自身进行数据获取
            Index.getInitialProps(this.props.krsOpt).then(data => {
                this.setState({
                    ...data
                }, () => {
                   //可有的一些操作
                });
            });
        }
    }

    render() {

        //得到 state 内的数据,进行逻辑判断和容错,然后渲染
        const { page, fetchData } = this.state;
        const [res] = fetchData || [];

        return <div className="detailBox">
            {
                res && res.data.map(item => {
                    return <div key={item.id}>{item.keyId}:{item.keyName}---{item.setContent}</div>
                })
            }
        </div>
    }
}


到此我们的第三个问题:【服务端 html 节点无法重用 】已经解决,但人不够完美,请继续看。

css 过滤

我们在写组件的时候大部分都会导入相关的 css 文件。


import './css/index.scss';//导入css

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }


    static async  getInitialProps() {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}

但是这个 css 文件在服务端无法执行,其实想想在服务端本来就不需要渲染 css 。为什么不直接干掉? 所以为了方便,我这里写了一个babel 插件,在编译的时候干掉 css 的导入代码。


/**
 * 删除 css 的引入
 * 可能社区已经有现成的插件但是不想费劲儿找了,还是自己写一个吧。 
 */
module.exports = function ({ types: babelTypes }) {
    return {
        name: "no-require-css",
        visitor: {
            ImportDeclaration(path, state) {
                let importFile = path.node.source.value;
                if(importFile.indexOf('.scss')>-1){
                    // 干掉css 导入
                    path.remove();
                }
            }
        }
    };
};

//.babelrc 中使用

 "plugins": [
                "./webpack/babel/plugin/no-require-css"  //引入        
            ]

动态路由的 SSR

现在要说一个更加核心的内容,也是本文的一个压轴亮点,可以说是全网唯一,我之前也看过很多文章和资料都没有细说这一块儿的实现。

不知道你有没有发现,上面我们已经一步一步的实现了 React SSR 同构 的完整流程,但是总感觉少点什么东西。

SPA模式下大部分都会实现组件分包和按需加载,防止所有代码打包在一个文件过大影响页面的加载和渲染,影响用户体验。

那么基于 SSR 的组件按需加载如何实现呢?

当然我们所限定按需的粒度是路由级别的,请求不同的路由动态加载对应的组件。

如何实现组件的按需加载?

webpack2 时期主要使用require.ensure方法来实现按需加载,他会单独打包指定的文件,在当下 webpack4,有了更加规范的的方式实现按需加载,那就是动态导入 import('./xx.js'),当然实现的效果和 require.ensure是相同的。

咱们这里只说如何借助这个规范实现按需加载的路由,关于动态导入的实现原理先按下不表。

我们都知道 import 方法传入一个js文件地址,返回值是一个 promise 对象,然后在 then 方法内回调得到按需的组件。他的原理其实就是通过 jsonp 的方式,动态请求脚本,然后在回调内得到组件。

import('../index').then(res=>{
    //xxxx
});

那现在我们已经得到了几个比较有用的信息。

  • 如何加载脚本 - import 结合 webpack 自动完成
  • 脚本是否加载完成 - 通过在 then 方法回调进行处理
  • 获取异步按组件 - 通过在 then 方法回调内获取

我们可以试着把上面的逻辑抽象成为一个组件,然后在路由配置的地方进行导入后,那么是不是就完成了组件的按需加载呢?

先看下按需加载组件, 目的是在 import 完成的时候得到按需的组件,然后更改容器组件的 state,将这个异步组件进行渲染。


/**
 * 按需加载的容器组件
 * @class Bundle
 * @extends {Component}
 */
export default class Async extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            COMPT: null
        };
    }

    UNSAFE_componentWillMount() {
        //执行组件加载
        if (!this.state.COMPT) {
            this.load(this.props);
        }
    }


    load(props) {
        this.setState({
            COMPT: null
        });
        //注意这里,返回Promise对象; C.default 指向按需组件
        props.load().then((C) => {
            this.setState({
                COMPT: C.default ? C.default : COMPT
            });
        });
    }

    render() {
        return this.state.COMPT ? this.props.children(this.state.COMPT) : <span>正在加载......</span>;
    }
}

Async 容器组件接收一个 props 传过来的 load 方法,返回值是 Promise类型,用来动态导入组件。

在生命周期 UNSAFE_componentWillMount 得到按需的组件,并将组件存储到 state.COMPT内,同时在 render 方法中判断这个状态的可用性,然后调用this.props.children 方法进行渲染。

//调用
const LazyPageCom = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}//返回函数组件
    </Async>
);

当然这只是其中一种方法,也有很多是通过 react-loadable 库来进行实现,但是实现思路基本相同,有兴趣的可以看下源码。

//参考代码
import React from 'react';
import Loadable from 'react-loadable';

//loading 组件
const Loading =()=>{
    return (
        <div>loading</div>
    ) 
}

//导出组件
export default Loadable({
    loader:import('../index'),
    loading:Loading
});

到这里我们已经实现了组件的按需加载,剩下就是配置到路由。

看下伪代码


//index.js

class Index extends React.Component {

    render() {
        return <div>detail</div>
    }
}


//detail.js

class Detail extends React.Component {

    render() {
        return <div>detail</div>
    }
}

//routes.js

//按需加载 index 组件
const AyncIndex = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}
    </Async>
);

//按需加载 detai 组件
const AyncDetail = (props) => (
    <Async load={() => import('../index')}>
        {(C) => <C {...props} />}
    </Async>
);

const routes = [

    {
        path: "/",
        exact: true,
        component: AyncIndex
    },
    {
        path: '/detail', exact: true,
        component: AyncDetail,
    }
];

结合路由的按需加载已经配置完成,先不管 server端 是否需要进行调整,此时的代码是可以运行的,按需也是 ok 的。

但是ssr无效了,查看网页源代码无内容。

动态路由 SSR 双端配置

ssr无效了,这是什么原因呢?

上面我们在做路由同构的时候,双端使用的是同一个 route配置文件routes-config.js,现在组件改成了按需加载,所以在路由查找后得到的组件发生改变了 - AyncDetail,AyncIndex,根本无法转换出组件内容。

ssr 模式下 server 端如何处理路由按需加载

其实很简单,也是参考客户端的处理方式,对路由配置进行二次处理。server 端在进行组件查找前,强制执行 import 方法,得到一个全新的静态路由表,再去进行组件的查找。


//获得静态路由

import routes from 'routes-config.js';//得到动态路由的配置

export async function getStaticRoutes() {

    const staticRoutes = [];//存放新路由

    for (; i < len; i++) {
        let item = routes[i];
       
        //存放静态路由
        staticRoutes.push({
            ...item,
            ...{
                component: (await item.component().props.load()).default
            }
        });
  
    }
    return staticRoutes; //返回静态路由
}

如今我们离目标更近了一步,server端已兼容了按需路由的查找。但是还没完!

我们这个时候访问页面的话,ssr 生效了,查看网页源代码可以看到对应的 html 内容。

但是页面上会显示直出的内容,然后显示<span>正在加载......</span> ,瞬间又变成直出的内容。

### ssr 模式下 client 端如何处理路由按需加载

这个是为什么呢?

是不是看的有点累了,再坚持一下就成功了。

其实有问题才是最好的学习方式,问题解决了,路就通了。

首先我们知道浏览器端会对已有的节点进行双端对比,如果对比失败就会重新渲染,这很明显就是个问题。

咱分析一下,首先服务端直出了 html 内容,而此时浏览器端js执行完后需要做按需加载,在按需加载前的组件默认的内容就是<span>正在加载......</span> 这个缺省内容和服务端直出的 html 内容完全不同,所以对比失败,页面会渲染成 <span>正在加载......</span>,然后按需加载完成后组件再次渲染,此时渲染的就是真正的组件了。

如何解决呢?

其实也并不复杂,只是不确定是否可行,试过就知道。

既然客户端需要处理按需,那么我们等这个按需组件加载完后再进行渲染是不是就可以了呢?

答案是:可以的!

如何按需呢?

向“服务端同学”学习,找到对应的组件并强制 执行import按需,只是这里不是转换为静态路由,只找到按需的组件完成动态加载即可。

既然有了思路,那就撸起代码。


import React,{createContext} from 'react';
import RootContext from './route-context';

export default class Index extends React.Component {
    constructor(props,context) {
        super(props);
    }

    render() {
        return <RootContext.Provider value={this.props.initialData||{}}>
            {this.props.children}
        </RootContext.Provider>
    }
}

//入口  app.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from '../';
import Provider from './provider';


//渲染入口
function renderUI(initialData) {
    ReactDOM.hydrate(<BrowserRouter><Provider initialData={initialData}>
        <Routes />
    </Provider>
    </BrowserRouter>, document.getElementById('rootEle'), (e) => {
    });
}

function entryIndex() {
    let APP_INIT_DATA = {};
    let state = true;

    //取得数据
    let stateText = document.getElementById('krs-server-render-data-BOX');
    
    //数据脱水
    if (stateText) {
        APP_INIT_DATA = JSON.parse(stateText.value || '{}');
    }


    if (APP_INIT_DATA) {//客户端渲染
        
        - renderUI(true, APP_INIT_DATA);
        //查找组件
        + matchComponent(document.location.pathname, routesConfig()).then(res => {
            renderUI(true, APP_INIT_DATA);
        });
    }
}

//执行入口
entryIndex();

matchComponent 是我封装的一个组件查找的方法,在文章开始已经介绍过类似的实现,代码就不贴了。

核心亮点说完,整个流程基本结束,剩下的都是些有的没的了,我打算要收工了。

其他

SEO 支持

页面的 SEO 效果取决于页面的主体内容和页面的 TDK(标题 title,描述 description,关键词 keyword)以及关键词的分布和密度,现在我们实现了 ssr所以页面的主体内容有了,那如何设置页面的标题并且让每个页面(路由)的标题都不同呢?

只要我们每请求一个路由的时候返回不同的 tdk 就可以了。

这里我在所对应组件数据预取的方法内加了约定,返回的数据为固定格式,必须包含 page 对象,page 对象内包含 tdk 的信息。

看代码瞬间就明白。


import './css/index.scss';

//组件
class Index extends React.Component{
    constructor(props){
        super(props);
    }

    static async  getInitialProps() {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');

        return {
            page:{
                tdk:{
                    title:'标题',
                    keyword:'关键词',
                    description:'描述'
                }
            }
            res:[fetch1,fetch2]
        }
    }

    render(){
        return <h1>{this.props.data.title}</h1>
    }
}

这样你的 tdk 可以根据你的需要设置成静态还是从接口拿到的。然后可以在 esj 模板里进行绑定,也可以在 componentDidMount通过 js
document.title=this.state.page.tdk.title设置页面的标题。

<!DOCTYPE html>
<html lang="en">
<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta http-equiv="X-UA-Compatible" content="ie=edge">
   <meta name="keywords" content="<%=page.tdk.keyword%>" />
   <meta name="description" content="content="<%=page.tdk.description%>" />
   <title><%=page.tdk.title%></title>
</head>
<body>
   <div id="rootEle">
       <%- htmlContent %>
   </div>
   <%- propsData %>
</body>
</html>
</body>
<%page.staticSource.js.forEach(function(item){%>

fetch 同构

可以使用isomorphic-fetchaxios或者whatwg-fetch + node-fetch 等库来实现支持双端的 fetch 数据请求,这里推荐使用axios 主要是比较方便。

TODO 和 思考

没有介绍结合 redux 状态管理的 ssr 实现,其实也不复杂,关键还是看业务中是否需要使用redux,因为文中已经实现了使用 context 传递数据,直接改成按store 传递也很容易,但是更多的还是对 react-redux 的应用。


//渲染入口 代码仅供参考 
function renderUI(initialData) {
   ReactDOM.hydrate(<BrowserRouter><Provider store={initialData}>
       <Routes />
   </Provider>
   </BrowserRouter>, document.getElementById('rootEle'), (e) => {
   });
}

服务端同构渲染虽然可以提升首屏的出现时间,利于 SEO,对低端用户友好,但是开发复杂度有所提高,代码需要兼容双端运行(runtime),还有一些库只能在浏览器端运行,在服务端加载会直接报错,这种情况就需要进行做一些特殊处理。

同时也会大大的增加服务端负载,当然这都容易解决,可以改用renderToNodeStream() 方法通过流式输出来提升服务端渲染性能,可以进行监控和扩容,所以是否需要 ssr 模式,还要看具体的产品线和用户定位。

最后

本文最初从 react ssr 的整体实现原理上进行说明,然后逐步的抛出问题,循序渐进的逐步解决,最终完成了整个React SSR 所需要处理的技术点,同时对每个技术点和问题做了详细的说明。

但实现方式并不唯一,还有很多其他的方式, 比如 next.js, umi.js,但是原理相似,具体差异我会接下来进行对比后输出。

源码参考

由于上面文中的代码较为零散,恐怕不能直接运行。为了方便大家的参考和学习,我把涉及到代码进行整理、完善和修改,增加了一些基础配置和工程化处理,目前已形成一个完整的开发骨架,可以直接运行看效果,所有的代码都在这个骨架里,欢迎star 欢迎 下载,交流学习。

项目代码地址: https://github.com/Bigerfe/koa-react-ssr

说点感想

很多东西都可以基于你现有的知识创造出来。

只要明白了其中的原理,然后梳理出实现的思路,剩下的就是撸代码了,期间会大量的自动或被动的从你现有的知识库里进行调取,一步一步的,只要不怕麻烦,都能搞得定。

这也是我为什么上来先要说下reac ssr 原理 的原因,因为它指导了我的实践。

全文都是自己亲手一个一个码出,也全部都是出自本人的理解,但个人文采有限,所以导致很多表达说的都是大白话,表达不够清楚的地方还请指出和斧正,但是真正的核心已全部涵盖。

希望本文的内容对你有所帮助,也可以对得住我这个自信的标题。

参考资料

https://github.com/ReactTrain...
https://reacttraining.com/rea...
https://blog.seosiwei.com/det...
https://www.jianshu.com/p/47c...


更多精彩好玩有用的前端内容,请关注公众号《前端张大胖》

查看原文

石坚 收藏了文章 · 2月12日

CSS 故障艺术

v2-c7a2db16babbdefec756d3eed6bc0fa4_hd

本文的主题是 Glitch Art,故障艺术。

什么是故障艺术?我们熟知的抖音的 LOGO 正是故障艺术其中一种表现形式。它有一种魔幻的感觉,看起来具有闪烁、震动的效果,很吸引人眼球。

故障艺术它模拟了画面信号出现故障导致成像错误的感觉。青色色块与红色色块无法重合就是这种故障的体现。从胶片时代开始到今天的数码时代,这种故障一直是观众非常熟悉的现象。即使抖音的 LOGO 只是静态的,大脑也会自己补完整个效果,甚至还会自己脑补信号干扰的噪音。

当然,广义的故障艺术不仅仅指这种效果,我觉得是很宽泛的,本文将介绍一些 CSS 能够模拟完成的故障艺术效果。

使用混合模式实现抖音 LOGO

首先从静态的开始,抖音的 LOGO 就是很好的一个例子。

image

它看着像是 3 个 J 形重叠在一起。而实际上,是两个 J 形重叠在一起,重叠部分表现为白色,这个特性,使用 CSS 的混合模式 mix-blend-mode 非常好实现,而单个 J 形示意图如下:

image

图片来源于知乎:为什么抖音的标志,看起来具有“电”“闪烁”“震动”的感觉?

单个 J 形其实是由 3/4圆 + 竖线 + 1/4圆组成,使用一个标签即可完成(加上两个伪元素)。

关键点

  • 主要借助伪元素实现了整体 J 结构,借助了 mix-blend-mode 实现融合效果
  • 利用 mix-blend-mode: lighten 混合模式实现两个 J 形结构重叠部分为白色

所以整个效果只需要两个标签:

<div class="j"></div>
<div class="j"></div>

简易 SASS 代码:

// 实现第一个 J
.j {
    position: absolute;

    &::before {
        content: "";
        ...
    }

    &::after {
        content: "";
        ...
    }
}

// 实现第二个 J,加上混合模式
.j:last-child {
    position: absolute;
    mix-blend-mode: lighten;

    &::before {
        content: "";
        ...
    }
    &::after {
        content: "";
        ...
    }
}

示意图如下(为了更好理解,加上了动画):

tiktok

完整的 DEMO:

使用 mix-blend-mode 实现抖音 LOGO

图片的 Glitch Art 风

当然,上面实现的是我们实现的 J 形的叠加,理解了这种技巧之后,我们可以把它运用到图片之上。

这里我们会运用到 background-blend-modemix-blend-mode

假设,我们有这样一张图:

image

只需要一个标签即可

<div class="mix"></div>

给两张同样的图片,叠加上 青色#0ff 和 红色#f00,并且错开一定的距离,两张图都要加上 background-blend-mode: lighten,其中一张再加上 mix-blend-mode: darken

.mix {
    width: 400px;
    height: 400px;
    background: url($img), #0ff;
    background-blend-mode: lighten;

  &::after {
    content: '';
    position: absolute;
    margin-left: 10px;
    width: 400px;
    height: 400px;
    background: url($img), #f00;
    background-blend-mode: lighten;
    mix-blend-mode: darken;
  }
}

得到如下效果:

image

这里与上述抖音 LOGO 的处理是有点不一样的,使用的混合模式也不止一种,简单解释下。

  1. 因为图片本身不是红色和青色的,所以需要通过 background-image 叠加上这两种颜色,并通过 background-blend-mode: lighten 让其表现出来
  2. 为了保持中间叠加部分的原色,需要再叠加一个 mix-blend-mode: darken 反向处理一下。(不理解的同学可以打开调试,手动关掉几个混合模式,自己感受感受即可)

完整的 DEMO:

图片的类抖音 LOGO Glitch 效果

动态类抖音风格 Glitch 效果

OK,有了上面的铺垫,我们接下来可以给这种效果加上动画。

关键点

  • 利用 mix-blend-mode: lighten 混合模式实现两段文字结构重叠部分为白色
  • 利用元素位移完成错位移动动画,形成视觉上的冲击效果

看看效果:

textglitch

本文篇幅有点长,代码就不上了,完整 DEMO 在这里:

类抖音 LOGO 文字故障效果

当然,我们也不是一定要使用混合模式去使得融合部分为白色,可以仅仅是使用这个配色效果,基于上面效果的另外一个版本,没有使用混合模式。

关键点

  • 利用了伪元素生成了文字的两个副本
  • 视觉效果由位移、遮罩、混合模式完成
  • 配色借鉴了抖音 LOGO 的风格

textglitch2

完整 DEMO 在这里:

CSS文字故障效果

仅仅使用配色没有使用混合模式的好处在于,对于每一个文字的副本,有了更大的移动距离和可以处理的空间。

Glitch Art 风格的 404 效果

稍微替换一下文本文案为 404,再添加上一些滤镜效果(hue-rotate()blur())嘿嘿,找到了一个可能实际可用的场景:

效果一:

404

效果二:

404

两个 404 效果的 Demo 如下:

其他配色效果

当然,不仅仅只有这一种红 + 青的配色效果。还有一些其他的配色及混合模式的搭配,如 黄 + 粉红 + 蓝配合 mix-blend-mode: multiply

然后,有的时候,效果不希望和背景混合在一起,可以使用 isolation: isolate 进行隔离。


好,上述效果可以归类为一个分类。接下来开启下一个分类

clip-path 登场

下半篇幅的主角主要是 clip-path

clip-path 一个非常有意思的 CSS 属性。

clip-path CSS 属性可以创建一个只有元素的部分区域可以显示的剪切区域。区域内的部分显示,区域外的隐藏。剪切区域是被引用内嵌的URL定义的路径或者外部 SVG 的路径。

也就是说,使用 clip-path 可以将一个容器切成我们想要的样子。

例如这样:

<div>TXET</div>
div  {
    margin: auto;
    padding: 10px;
    line-height: 1.2;
    font-size: 60px;
    background: #ddd;
}

正常是这样的:

image

使用 clip-path 剪裁为一个平行四边形:

div  {
    margin: auto;
    padding: 10px;
    line-height: 1.2;
    font-size: 60px;
    background: #ddd;
+   clip-path: polygon(35% 0, 85% 0, 75% 100%, 25% 100%);
}

结果如下:

image

那么,思路就有了,我们可以将一个文字复制几个副本,重叠在一起,再分别裁剪这几个副本进行位移动画即可。

使用 clip-path 实现文字断裂动画

我们还是使用元素的 ::before::after 两个伪元素复制两份副本,再分别使用 clip-path 进行剪裁,再使用 transform 进行控制。

核心代码:

<div data-text="Text Crack">
    <span>Text Crack</span>
</div>
div {
    position: relative;
    animation: shake 2.5s linear forwards;
}

div span {
    clip-path: polygon(10% 0%, 44% 0%, 70% 100%, 55% 100%);
}

div::before,
div::after {
    content: attr(data-text);
    position: absolute;
    top: 0;
    left: 0;
}

div::before {
    animation: crack1 2.5s linear forwards;
    clip-path: polygon(0% 0%, 10% 0%, 55% 100%, 0% 100%);
}

div::after {
    animation: crack2 2.5s linear forwards;
    clip-path: polygon(44% 0%, 100% 0%, 100% 100%, 70% 100%);
}

// 元素晃动,断裂前摇
@keyframes shake {    
    ...
}

@keyframes crack1 {
    0%,
    95% {
        transform: translate(-50%, -50%);
    }

    100% {
        transform: translate(-55%, -45%);
    }
}

@keyframes crack2 {
    0%,
    95% {
        transform: translate(-50%, -50%);
    }

    100% {
        transform: translate(-45%, -55%);
    }
}

可以得到这样的效果:

textcrack

完整的 Demo:

clip-path 实现文字断裂效果

这个效果,最早的版本见于这位作者:George W. Park

clip-path 的 Glitch Art

OK,继续,有了上面的铺垫之后,接下来,我们把这个效果作用于图片之上,并且再添加上动画。

随便选一张图片:

image

哇哦,非常的赛博朋克。

实现动画的关键在于:

  • 使用元素的两个伪元素,生成图片的两个副本
  • 使用 clip-path 对两个副本图片元素进行裁剪,然后进行位移、transform变换、添加滤镜等一系列操作。

简单贴一下伪代码:

<div></div>
$img: "https://mzz-files.oss-cn-shenzhen.aliyuncs.com///uploads/U1002433/0cb5e044a1f0f7fc15f61264ee97ac1f.png";

div {
    position: relative;
    width: 658px;
    height: 370px;
    background: url($img) no-repeat;
    animation: main-img-hide 16s infinite step-end;
}

div::before,
div::after {
    position: absolute;
    width: 658px;
    height: 370px;
    top: 0;
    left: 0;
    background: inherit;
}

div::after {
    content: "";
    animation: glitch-one 16s infinite step-end;
}

div::before {
    content: "";
    animation: glitch-two 16s infinite 1s step-end;
}

@keyframes glitch-one {
    @for $i from 20 to 30 {
        #{$i / 2}% {
            left: #{randomNum(200, -100)}px;
            clip-path: inset(#{randomNum(150, 30)}px 0 #{randomNum(150, 30)}px);
        }
    }

    15.5% {
        clip-path: inset(10px 0 320px);
        left: -20px;
    }
    16% {
        clip-path: inset(10px 0 320px);
        left: -10px;
        opacity: 0;
    }
    ....
}

@keyframes glitch-two {
    @for $i from 40 to 50 {
        #{$i / 2}% {
            left: #{randomNum(200, -100)}px;
            clip-path: inset(#{randomNum(180)}px 0 #{randomNum(180)}px);
        }
    }

    25.5% {
        clip-path: inset(10px 0 320px);
        left: -20px;
    }
    26% {
        clip-path: inset(10px 0 320px);
        left: -10px;
        opacity: 0;
    }
   ...
}

@keyframes main-img-hide {
    5% {
        filter: invert(1);
    }
    ...
}

由于动画部分代码量太多,所以使用了 SASS 循环函数随机生成了部分。如果手动控制,效果其实还会更好,当然,调试动画消耗的时间会更多。

看看效果,虽然 CSS 能力有限,但实际的效果也不是说那么的差:

imgglitch

GIF 图太大,掉帧太多,效果大打折扣。完整的 Demo 及效果,你可以戳这里:

clip-path 实现图片的故障艺术风格动画

总结

本文重点介绍了纯 CSS 下使用混合模式和 clip-path 实现的一些故障艺术(Glitch Art),当然,上述的几个效果都不仅仅是靠这两个属性单打独斗就能完成的。

在其中,transformfilter 也在其中发挥了很重要的作用。当然仅仅使用 transformfilter 也能够实现一些基础的故障艺术效果,这个读者们感兴趣的可以自己多加尝试。如果想使用于生产环境,需要考虑 mix-blend-modeclip-path 的兼容性问题。

我自己对 Glitch Art 的理解其实也比较浅显,只是觉得使用 CSS 去实现这样一些类似的效果比较有意思,就动手尝试实践了一番,相关术语或者名词理解错误烦请各位谅解指出。

最后

好了,本文到此结束,希望对你有帮助 :)

更多精彩 CSS 技术文章汇总在我的 Github -- iCSS ,持续更新,欢迎点个 star 订阅收藏。

更多精彩有趣的 CSS 效果,欢迎来这里看看 CSS 灵感 -- 在这里找到写 CSS 的灵感

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

查看原文

石坚 赞了文章 · 2月12日

微信小程序wx.request请求数据报错:不在以下 request 合法域名列表中

首先写一个后台的数据接口,地址是:http://localhost/weicms/index...

然后使用wx.request调用后台数据接口的地址

示例代码

1 wx.request({  
2   url: 'http://localhost/weicms/index.php?s =/addon/Cms/Cms/getList',  
3   data: {  
4     x: '',  
5     y: ''  
6   },  
7   header: {  
8     'content-type': 'application/json'// 默认值  
9   },  
10  success (res) {  
11     console.log(res.data)  
12    }  
13  })

运行代码,效果如下图:

1.jpg

从上图中看到页面一片空白,没有获取到数据,并且控制台报错(request 合法域名校验出错;http://localhost 不在以下 request 合法域名列表中)

为何出现这种错误?

打开wx.request网络请求的开发文档可以看到

2.jpg

上面截图中红色框就是问题所在(小程序服务器域名配置中是不能使用IP地址跟localhost),示例代码中wx.request请求的url地址包含localhost,因此出错。

但是一般开发过程中都要先在本地开发调试。如果没法使用ip地址跟localhos,本地开发调试过程中如何获取数据呢,有没有办法在本地开发调试的时候屏蔽这个错误呢?

答案是有的。开发文档中指出了可以跳过域名校验,如下图:

3.png

具体在哪里开启不检验域名的选项呢?在微信开发者工具中,点击详情后,选中不检验合法域名,如下图所示:

4.jpg

此时,再次运行代码后,效果如下图:

5.jpg

从上图看到数据已经成功获取到了,且控制也没有报错,只是提示:配置中关闭合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书检查

查看原文

赞 65 收藏 2 评论 0

石坚 发布了文章 · 2月11日

基于umi的seo预编译

seo-idea-lightbulbs-ss-1920的副本.jpg

一、介绍

单应用框架的项目,打包出来的文件中只有一个index.html作为入口,根据页面的路由加载对应页面的js。

当项目需要支持SEO时,在条件允许的情况下可以选择node作为同构方案,配合react-router-config匹配routes中的components,再通过rendersToString方法打印出内容,注入到html模板中。
此方案的优点是可以支持动态路由、后端动态获取数据。缺点是每次访问都要生成一次、引进了node服务。

如果在网站被访问之前就把html预编译好,多个路由对应多个html,这就是下面要介绍的预编译方案。

在umi项目中配置预编译非常简单
umirc.js

plugins: [['@umijs/plugin-prerender']],
ssr: true,

二、pre-render

pre-render插件在umi打包结束后,按照routes里的url一个个访问ssr,类似于构造虚拟访问来生成html。

routes除非指定了黑白名单,否则将生成所有html
umirc.js

[
      '@umijs/plugin-prerender',
      { 
      include: ['/'],
      exclude: ['/help']
      },
]

三、ssr

把umirc.js中ssr设置成true后,dist文件夹下会多生成了一个umi.server.js文件,它是以/src/pages/.umi文件为入口、囊括了其他所有dist/*.js生成的大文件,并且经过了环境变量上的处理。

1.区分browser打包

  • __IS_BROWSER

/src/pages/.umi是每次打包后都会生成的中间结果,查看代码会发现它是以__IS_BROWSER变量区分浏览器、node环境,而umi.server.js则是把__IS_BROWSER变量替换成boolean的结果,因此在业务代码中,可以直接使用__IS_BROWSER这一变量区分你的逻辑。

例如,页面中一开始有loading等待缓冲的效果,可以使用__IS_BROWSER将loading取消,避免编译出来的内容无效。

  • runInMockContext

上面提到的loading效果的区分,也可以通过在配置中自定义环境变量

[
      '@umijs/plugin-prerender',
      { 
        runInMockContext: {
            __isCustomEnv: true
        }
      }
]

2.mockWindow

umi-server在调用umi.server.js时会给global变量附加mock过的window对象,例如,在业务代码中访问window.location.url时,获取到的是浏览器环境一样的结果。

3.getInitialProps

在预编译之前,想要给html中放入一些内容,但这些内容又不需要在实际业务中展示。这时可以选择给routes下定义的component添加getInitialProps方法

(1)静态内容
function Index(props){
    return props.nodeContent || 'hello world'
}

Index.getInitialProps = ()=>({
  nodeContent: '我特地为seo增加的内容'
})

export defalut Index

将不经常更新的内容放到getInitialProps中写死,生成的内容供seo抓取,不过在js执行后会将其刷新掉。

(2)动态内容
function Index(props){
    return props.nodeContent || 'hello world'
}

Index.getInitialProps = ()=>{
    return API.getData();   //promise ==> {nodeContent:'动态内容'}
}

export defalut Index

返回一个promise也能传递到组件的props中,这个适合于内容不固定的场景。

(3)动态路由

有些场景下需要根据路由信息来传递给后端获取动态内容
假如项目原先的动态路由配置如下

{ path: '/users/:id', component: '../pages/users' },

另外新建个文件专门对users下的所有id进行枚举
route-users-config.js

const usersRoutes = [
{ path: '/users/10', component: '../pages/users' },
{ path: '/users/11', component: '../pages/users' },
{ path: '/users/99', component: '../pages/users' },
...
];
export defalut usersRoutes

将usersRoutes追加到umi的路由配置中,再将usersRoutes的path罗列出来追加到pre-render的include中,就可生成枚举的动态路由html

动态路由的信息可以在props中获取

function Index(props){
    return props.nodeContent || 'hello world'
}

Index.getInitialProps = ({location})=>{
    return API.getData(location.pathname);   //promise ==> {nodeContent:'动态内容'}
}

export defalut Index

4.动态title、meta

百度搜索TDK的需要,给每个页面生成不同的keywords、description、title,提高搜索引擎的收录权重。

import Helmet from 'react-helmet';

<Helmet>
        <meta name="description" content={`拥有优秀的大数据分析能力,可以提供专业的用户运营和数据分析经验,提供从数据埋点采集到数据分析的全链路用户增长应用体系,支持私有化部署。`} />
        <title>{`一站式数据+增长平台`}</title>
</Helmet>
[
      '@umijs/plugin-prerender',
      {
        runInMockContext: {
          __isCustomEnv: true
        },
        postProcessHtml: ($) => {
          const helmet = Helmet.renderStatic();
          const title = helmet.title.toString();
          const meta = helmet.meta.toString();
          const link = helmet.link.toString();

          if (title !== '<title data-react-helmet="true"></title>') {
            $('html head').prepend(title);
          }
          $('html head').append(meta)
          $('html head').append(link)

          return $;
        }
      },
    ],

四、拓展

1.图片

umi默认给图片加了阈值,当小于这个值时生成的图片使用base64。但在预编译时生成的html中会产生大量的base64字符,使得html变大。
这里可以设置图片的阈值,减小html的包袱
umirc.js

  chainWebpack(config) {
    config.module
      .rule('exclude')
      .use('url-loader')
      .tap((options) => {
        return {
          ...options,
          limit: 1,
        };
      });
  }

2.页面闪烁

umi使用了默认的renderToString方法,renderToString在root元素上加了data-reactroot属性,在判断内容没有变化的情况下不重新渲染。
但由于umi使用了document.ejs渲染整体html,data-reactroot属性被加在了html根元素上,所以导致第一次进入页面时会出现闪烁。

简单的解决方案是:
1.将data-reactroot属性写死在root元素上
2.在最外部加一层className,功能是设置display:none。
注意点:不能作为style直接写在元素上,会被搜索引擎识别。也不能将className设置为类似hide字样的名称,也容易被识别

查看原文

赞 3 收藏 1 评论 0

石坚 关注了专栏 · 1月8日

阿里云栖号

汇集阿里技术精粹-yq.aliyun.com

关注 11808

认证与成就

  • 获得 78 次点赞
  • 获得 5 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 5 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-11-27
个人主页被 675 人浏览