◆ join とりあえずくっつける
  ◆ 途中の絶対パスは無意味
◆ resolve 通常は常に絶対パスを返す
  ◆ 後ろから順に見て絶対パスがあればそれより前の引数は無視される
  ◆ 絶対パスがないならカレントディレクトリからの相対パスとして処理される

よくどう違うのかわからなくなるのでソースをみて比べてみました
バージョンは 10.12.0 のものです
posix と win32 で別の関数になりますが対象は posix のものです

join

ソースはこうなってます
https://github.com/nodejs/node/blob/v10.12.0/lib/path.js#L1149
  join: function join() {
if (arguments.length === 0)
return '.';
var joined;
for (var i = 0; i < arguments.length; ++i) {
var arg = arguments[i];
assertPath(arg);
if (arg.length > 0) {
if (joined === undefined)
joined = arg;
else
joined += '/' + arg;
}
}
if (joined === undefined)
return '.';
return posix.normalize(joined);
},

こういう for で地道に手続き的な処理していくのは読みやすくないので苦手です
やってることは各引数を 「/」 で挟んで結合です
引数で長さが 0 のものはスキップしています
空になるケースは 「.」 を返してます
空じゃない場合は posix.normalize を通してます
posix.normalize は path.normalize のことで ../ とかを解決してくれます

簡単に書けばこういうことですね

function join(...args){
const path = args.filter(e => e.length).join("/")
return path === "" ? "." : normalize(path)
}

filter とか join とかのほうが圧倒的に見やすくわかりやすいですが for で手続き的なことするほうがパフォーマンスは優れます
こういうよく使う標準機能はパフォーマンスを優先して長い書き方にしてるのだと思います

resolve

ソースはこうなってます
https://github.com/nodejs/node/blob/v10.12.0/lib/path.js#L1073
  resolve: function resolve() {
var resolvedPath = '';
var resolvedAbsolute = false;
var cwd;

for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) {
var path;
if (i >= 0)
path = arguments[i];
else {
if (cwd === undefined)
cwd = process.cwd();
path = cwd;
}

assertPath(path);

// Skip empty entries
if (path.length === 0) {
continue;
}

resolvedPath = path + '/' + resolvedPath;
resolvedAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH;
}

// At this point the path should be resolved to a full absolute path, but
// handle relative paths to be safe (might happen when process.cwd() fails)

// Normalize the path
resolvedPath = normalizeString(resolvedPath, !resolvedAbsolute, '/',
isPosixPathSeparator);

if (resolvedAbsolute) {
if (resolvedPath.length > 0)
return '/' + resolvedPath;
else
return '/';
} else if (resolvedPath.length > 0) {
return resolvedPath;
} else {
return '.';
}
},

こっちも複雑な for で読む気が進まないですが 引数を後ろから見てるようです
後ろから引数を「/」区切りでくっつけていって 最初が「/」だとそこが絶対パスなのでそこで終わりです
絶対パスがなかった場合はループ変数が -1 の時まで実行しています
-1 はカレントフォルダになっていて 暗黙的にカレントフォルダが最初の引数に渡されたような動きになります
resolve は絶対パスを返すので 絶対パスになってない場合はカレントフォルダからの相対パスとして絶対パスになるようにしているわけですね

こっちも空文字の引数はスキップされます
気になったのは cwd を外側に置いてるところです
使い回すからかと思ったのですが最大で一度しか呼び出されないので理由がわかりません
var は関数スコープだからとか考えましたが path は for の内側で宣言されてます
特に意味はなく昔は複数箇所で使っててその名残とかなのかもしれません


コメント以降は normalize です
normalizeString は path.normalize でも使われていますが その結果の処理が異なるのでこっちだと normalize メソッドを使わず直接書かれています
こっちは絶対パスじゃないならカレントディレクトリからの相対パスとするので常に絶対パスになるはずですが カレントディレクトリの取得に失敗した場合の対処らしい処理が書かれてます
process.cwd を上書きして () => "." とかにしてみると例外時に動きを確認できます

normalize はほぼそのままなのでそれまでの部分を簡単に書いてみると

function resolve(...args) {
let resolved
for (const part of args.reverse().filter(e => e)) {
resolved = part + "/" + resolved
if (resolved.startsWith("/")) break
}
if (!resolved.startsWith("/")) {
resolved = process.cwd() + "/" + resolved
}
///
}

になると思います

違い

resolve は常に絶対パスにするので 相対パスを書くと全然違う結果になります
ですが最初の引数が絶対パスだと基本は同じ結果です
でも中でやってることは結構違うのですよね

resolve の方は後ろから見て絶対パスがそこまでなので 途中に絶対パスがあるとそれ以前は無視されます
join は気にせず全部くっつけるので途中が絶対パスでも気にしません
最初以外「/」から始めても意味ないです

これが一番の違いなのかなと思います